diff --git a/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml b/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml
index 591148b41ddb3b069f137c28ac851d7ed26e2e30..b09a092c02a503eec6c154bb9468bb96aaa571a5 100644
--- a/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml
+++ b/glfm_specification/input/gitlab_flavored_markdown/glfm_example_status.yml
@@ -10,5 +10,5 @@
   skip_running_conformance_static_tests: false # NOT YET SUPPORTED
   skip_running_conformance_wysiwyg_tests: false # NOT YET SUPPORTED
   skip_running_snapshot_static_html_tests: false # NOT YET SUPPORTED
-  skip_running_snapshot_wysiwyg_html_tests: false # NOT YET SUPPORTED
-  skip_running_snapshot_prosemirror_json_tests: false # NOT YET SUPPORTED
+  skip_running_snapshot_wysiwyg_html_tests: false
+  skip_running_snapshot_prosemirror_json_tests: false
diff --git a/scripts/lib/glfm/render_wysiwyg_html_and_json.js b/scripts/lib/glfm/render_wysiwyg_html_and_json.js
index 58b440d7ab2661ad87683513b2ce4a7869ac27bc..ed8bfdb463874cc2bbe862f39c1127c545d4795e 100644
--- a/scripts/lib/glfm/render_wysiwyg_html_and_json.js
+++ b/scripts/lib/glfm/render_wysiwyg_html_and_json.js
@@ -1,117 +1,7 @@
 import fs from 'fs';
-import { DOMSerializer } from 'prosemirror-model';
 import jsYaml from 'js-yaml';
-// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js
-//  See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan
-import Blockquote from '~/content_editor/extensions/blockquote';
-import Bold from '~/content_editor/extensions/bold';
-import BulletList from '~/content_editor/extensions/bullet_list';
-import Code from '~/content_editor/extensions/code';
-import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
-import DescriptionItem from '~/content_editor/extensions/description_item';
-import DescriptionList from '~/content_editor/extensions/description_list';
-import Details from '~/content_editor/extensions/details';
-import DetailsContent from '~/content_editor/extensions/details_content';
-import Division from '~/content_editor/extensions/division';
-import Emoji from '~/content_editor/extensions/emoji';
-import Figure from '~/content_editor/extensions/figure';
-import FigureCaption from '~/content_editor/extensions/figure_caption';
-import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
-import FootnoteReference from '~/content_editor/extensions/footnote_reference';
-import FootnotesSection from '~/content_editor/extensions/footnotes_section';
-import HardBreak from '~/content_editor/extensions/hard_break';
-import Heading from '~/content_editor/extensions/heading';
-import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
-import Image from '~/content_editor/extensions/image';
-import InlineDiff from '~/content_editor/extensions/inline_diff';
-import Italic from '~/content_editor/extensions/italic';
-import Link from '~/content_editor/extensions/link';
-import ListItem from '~/content_editor/extensions/list_item';
-import OrderedList from '~/content_editor/extensions/ordered_list';
-import Strike from '~/content_editor/extensions/strike';
-import Table from '~/content_editor/extensions/table';
-import TableCell from '~/content_editor/extensions/table_cell';
-import TableHeader from '~/content_editor/extensions/table_header';
-import TableRow from '~/content_editor/extensions/table_row';
-import TaskItem from '~/content_editor/extensions/task_item';
-import TaskList from '~/content_editor/extensions/task_list';
-import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
-import { createTestEditor } from 'jest/content_editor/test_utils';
 import { setTestTimeout } from 'jest/__helpers__/timeout';
-
-const tiptapEditor = createTestEditor({
-  extensions: [
-    Blockquote,
-    Bold,
-    BulletList,
-    Code,
-    CodeBlockHighlight,
-    DescriptionItem,
-    DescriptionList,
-    Details,
-    DetailsContent,
-    Division,
-    Emoji,
-    FootnoteDefinition,
-    FootnoteReference,
-    FootnotesSection,
-    Figure,
-    FigureCaption,
-    HardBreak,
-    Heading,
-    HorizontalRule,
-    Image,
-    InlineDiff,
-    Italic,
-    Link,
-    ListItem,
-    OrderedList,
-    Strike,
-    Table,
-    TableCell,
-    TableHeader,
-    TableRow,
-    TaskItem,
-    TaskList,
-  ],
-});
-
-async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) {
-  let prosemirrorDocument;
-  try {
-    const { document } = await deserializer.deserialize({ schema, content: markdown });
-    prosemirrorDocument = document;
-  } catch (e) {
-    const errorMsg = `Error - check implementation:\n${e.message}`;
-    return {
-      html: errorMsg,
-      json: errorMsg,
-    };
-  }
-
-  const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(
-    prosemirrorDocument.content,
-  );
-  const htmlString = documentFragment.firstChild.outerHTML;
-
-  const json = prosemirrorDocument.toJSON();
-  const jsonString = JSON.stringify(json, null, 2);
-  return { html: htmlString, json: jsonString };
-}
-
-function renderHtmlAndJsonForAllExamples(markdownExamples) {
-  const { schema } = tiptapEditor;
-  const deserializer = createMarkdownDeserializer();
-  const exampleNames = Object.keys(markdownExamples);
-
-  return exampleNames.reduce(async (promisedExamples, exampleName) => {
-    const markdown = markdownExamples[exampleName];
-    const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer);
-    const examples = await promisedExamples;
-    examples[exampleName] = htmlAndJson;
-    return examples;
-  }, Promise.resolve({}));
-}
+import { renderHtmlAndJsonForAllExamples } from 'jest/content_editor/render_html_and_json_for_all_examples';
 
 /* eslint-disable no-undef */
 jest.mock('~/emoji');
diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js
index 9b83ced10e1bb91c982c773ce960b0d7ee0f214d..5da6676cdc16726e37d8e7c914de28cd4fcfb720 100644
--- a/spec/frontend/__helpers__/matchers/index.js
+++ b/spec/frontend/__helpers__/matchers/index.js
@@ -2,3 +2,4 @@ export * from './to_have_sprite_icon';
 export * from './to_have_tracking_attributes';
 export * from './to_match_interpolated_text';
 export * from './to_validate_json_schema';
+export * from './to_match_expected_for_markdown';
diff --git a/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js
new file mode 100644
index 0000000000000000000000000000000000000000..829f6ba9770824ef2feffd874a8a9eeb1e8520c8
--- /dev/null
+++ b/spec/frontend/__helpers__/matchers/to_match_expected_for_markdown.js
@@ -0,0 +1,60 @@
+export function toMatchExpectedForMarkdown(
+  received,
+  deserializationTarget,
+  name,
+  markdown,
+  errMsg,
+  expected,
+) {
+  const options = {
+    comment: `Markdown deserialization to ${deserializationTarget}`,
+    isNot: this.isNot,
+    promise: this.promise,
+  };
+
+  const EXPECTED_LABEL = 'Expected';
+  const RECEIVED_LABEL = 'Received';
+  const isExpand = (expand) => expand !== false;
+  const forMarkdownName = `for Markdown example '${name}':\n${markdown}`;
+  const matcherName = `toMatchExpected${
+    deserializationTarget === 'HTML' ? 'Html' : 'Json'
+  }ForMarkdown`;
+
+  let pass;
+
+  // If both expected and received are deserialization errors, force pass = true,
+  // because the actual error messages can vary across environments and cause
+  // false failures (e.g. due to jest '--coverage' being passed in CI).
+  const errMsgRegExp = new RegExp(errMsg);
+  const errMsgRegExp2 = new RegExp(errMsg);
+
+  if (errMsgRegExp.test(expected) && errMsgRegExp2.test(received)) {
+    pass = true;
+  } else {
+    pass = received === expected;
+  }
+
+  const message = pass
+    ? () =>
+        // eslint-disable-next-line prefer-template
+        this.utils.matcherHint(matcherName, undefined, undefined, options) +
+        '\n\n' +
+        `Expected HTML to NOT match:\n${expected}\n\n${forMarkdownName}`
+    : () => {
+        return (
+          // eslint-disable-next-line prefer-template
+          this.utils.matcherHint(matcherName, undefined, undefined, options) +
+          '\n\n' +
+          this.utils.printDiffOrStringify(
+            expected,
+            received,
+            EXPECTED_LABEL,
+            RECEIVED_LABEL,
+            isExpand(this.expand),
+          ) +
+          `\n\n${forMarkdownName}`
+        );
+      };
+
+  return { actual: received, expected, message, name: matcherName, pass };
+}
diff --git a/spec/frontend/content_editor/markdown_snapshot_spec.js b/spec/frontend/content_editor/markdown_snapshot_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa35a99073e19163d70df7a5215a0fd70a99c03d
--- /dev/null
+++ b/spec/frontend/content_editor/markdown_snapshot_spec.js
@@ -0,0 +1,23 @@
+import path from 'path';
+import { describeMarkdownSnapshots } from 'jest/content_editor/markdown_snapshot_spec_helper';
+
+jest.mock('~/emoji');
+
+const glfmSpecificationDir = path.join(__dirname, '..', '..', '..', 'glfm_specification');
+
+const glfmExampleSnapshotsDir = path.join(
+  __dirname,
+  '..',
+  '..',
+  'fixtures',
+  'glfm',
+  'example_snapshots',
+);
+
+// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing
+// for documentation on this spec.
+describeMarkdownSnapshots(
+  'CE markdown snapshots in ContentEditor',
+  glfmSpecificationDir,
+  glfmExampleSnapshotsDir,
+);
diff --git a/spec/frontend/content_editor/markdown_snapshot_spec_helper.js b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js
new file mode 100644
index 0000000000000000000000000000000000000000..05d4a0919216abe5f94429d8384429ea2829c54e
--- /dev/null
+++ b/spec/frontend/content_editor/markdown_snapshot_spec_helper.js
@@ -0,0 +1,105 @@
+// See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#markdown-snapshot-testing
+// for documentation on this spec.
+
+import fs from 'fs';
+import path from 'path';
+import jsYaml from 'js-yaml';
+import { pick } from 'lodash';
+import {
+  IMPLEMENTATION_ERROR_MSG,
+  renderHtmlAndJsonForAllExamples,
+} from './render_html_and_json_for_all_examples';
+
+const filterExamples = (examples) => {
+  const focusedMarkdownExamples = process.env.FOCUSED_MARKDOWN_EXAMPLES?.split(',') || [];
+  if (!focusedMarkdownExamples.length) {
+    return examples;
+  }
+  return pick(examples, focusedMarkdownExamples);
+};
+
+const loadExamples = (dir, fileName) => {
+  const yaml = fs.readFileSync(path.join(dir, fileName));
+  const examples = jsYaml.safeLoad(yaml, {});
+  return filterExamples(examples);
+};
+
+// eslint-disable-next-line jest/no-export
+export const describeMarkdownSnapshots = (
+  description,
+  glfmSpecificationDir,
+  glfmExampleSnapshotsDir,
+) => {
+  let actualHtmlAndJsonExamples;
+  let skipRunningSnapshotWysiwygHtmlTests;
+  let skipRunningSnapshotProsemirrorJsonTests;
+
+  const exampleStatuses = loadExamples(
+    path.join(glfmSpecificationDir, 'input', 'gitlab_flavored_markdown'),
+    'glfm_example_status.yml',
+  );
+  const markdownExamples = loadExamples(glfmExampleSnapshotsDir, 'markdown.yml');
+  const expectedHtmlExamples = loadExamples(glfmExampleSnapshotsDir, 'html.yml');
+  const expectedProseMirrorJsonExamples = loadExamples(
+    glfmExampleSnapshotsDir,
+    'prosemirror_json.yml',
+  );
+
+  beforeAll(async () => {
+    return renderHtmlAndJsonForAllExamples(markdownExamples).then((examples) => {
+      actualHtmlAndJsonExamples = examples;
+    });
+  });
+
+  describe(description, () => {
+    const exampleNames = Object.keys(markdownExamples);
+
+    describe.each(exampleNames)('%s', (name) => {
+      const exampleNamePrefix = 'verifies conversion of GLFM to';
+      skipRunningSnapshotWysiwygHtmlTests =
+        exampleStatuses[name]?.skip_running_snapshot_wysiwyg_html_tests;
+      skipRunningSnapshotProsemirrorJsonTests =
+        exampleStatuses[name]?.skip_running_snapshot_prosemirror_json_tests;
+
+      const markdown = markdownExamples[name];
+
+      if (skipRunningSnapshotWysiwygHtmlTests) {
+        it.todo(`${exampleNamePrefix} HTML: ${skipRunningSnapshotWysiwygHtmlTests}`);
+      } else {
+        it(`${exampleNamePrefix} HTML`, async () => {
+          const expectedHtml = expectedHtmlExamples[name].wysiwyg;
+          const { html: actualHtml } = actualHtmlAndJsonExamples[name];
+
+          // noinspection JSUnresolvedFunction (required to avoid RubyMine type inspection warning, because custom matchers auto-imported via Jest test setup are not automatically resolved - see https://youtrack.jetbrains.com/issue/WEB-42350/matcher-for-jest-is-not-recognized-but-it-is-runable)
+          expect(actualHtml).toMatchExpectedForMarkdown(
+            'HTML',
+            name,
+            markdown,
+            IMPLEMENTATION_ERROR_MSG,
+            expectedHtml,
+          );
+        });
+      }
+
+      if (skipRunningSnapshotProsemirrorJsonTests) {
+        it.todo(
+          `${exampleNamePrefix} ProseMirror JSON: ${skipRunningSnapshotProsemirrorJsonTests}`,
+        );
+      } else {
+        it(`${exampleNamePrefix} ProseMirror JSON`, async () => {
+          const expectedJson = expectedProseMirrorJsonExamples[name];
+          const { json: actualJson } = actualHtmlAndJsonExamples[name];
+
+          // noinspection JSUnresolvedFunction
+          expect(actualJson).toMatchExpectedForMarkdown(
+            'JSON',
+            name,
+            markdown,
+            IMPLEMENTATION_ERROR_MSG,
+            expectedJson,
+          );
+        });
+      }
+    });
+  });
+};
diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ec1307d9b8f949244449fab52d94c9447b3073b
--- /dev/null
+++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js
@@ -0,0 +1,113 @@
+import { DOMSerializer } from 'prosemirror-model';
+// TODO: DRY up duplication with spec/frontend/content_editor/services/markdown_serializer_spec.js
+//  See https://gitlab.com/groups/gitlab-org/-/epics/7719#plan
+import Blockquote from '~/content_editor/extensions/blockquote';
+import Bold from '~/content_editor/extensions/bold';
+import BulletList from '~/content_editor/extensions/bullet_list';
+import Code from '~/content_editor/extensions/code';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import DescriptionItem from '~/content_editor/extensions/description_item';
+import DescriptionList from '~/content_editor/extensions/description_list';
+import Details from '~/content_editor/extensions/details';
+import DetailsContent from '~/content_editor/extensions/details_content';
+import Division from '~/content_editor/extensions/division';
+import Emoji from '~/content_editor/extensions/emoji';
+import Figure from '~/content_editor/extensions/figure';
+import FigureCaption from '~/content_editor/extensions/figure_caption';
+import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
+import FootnoteReference from '~/content_editor/extensions/footnote_reference';
+import FootnotesSection from '~/content_editor/extensions/footnotes_section';
+import HardBreak from '~/content_editor/extensions/hard_break';
+import Heading from '~/content_editor/extensions/heading';
+import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
+import Image from '~/content_editor/extensions/image';
+import InlineDiff from '~/content_editor/extensions/inline_diff';
+import Italic from '~/content_editor/extensions/italic';
+import Link from '~/content_editor/extensions/link';
+import ListItem from '~/content_editor/extensions/list_item';
+import OrderedList from '~/content_editor/extensions/ordered_list';
+import Strike from '~/content_editor/extensions/strike';
+import Table from '~/content_editor/extensions/table';
+import TableCell from '~/content_editor/extensions/table_cell';
+import TableHeader from '~/content_editor/extensions/table_header';
+import TableRow from '~/content_editor/extensions/table_row';
+import TaskItem from '~/content_editor/extensions/task_item';
+import TaskList from '~/content_editor/extensions/task_list';
+import createMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
+import { createTestEditor } from 'jest/content_editor/test_utils';
+
+const tiptapEditor = createTestEditor({
+  extensions: [
+    Blockquote,
+    Bold,
+    BulletList,
+    Code,
+    CodeBlockHighlight,
+    DescriptionItem,
+    DescriptionList,
+    Details,
+    DetailsContent,
+    Division,
+    Emoji,
+    FootnoteDefinition,
+    FootnoteReference,
+    FootnotesSection,
+    Figure,
+    FigureCaption,
+    HardBreak,
+    Heading,
+    HorizontalRule,
+    Image,
+    InlineDiff,
+    Italic,
+    Link,
+    ListItem,
+    OrderedList,
+    Strike,
+    Table,
+    TableCell,
+    TableHeader,
+    TableRow,
+    TaskItem,
+    TaskList,
+  ],
+});
+
+export const IMPLEMENTATION_ERROR_MSG = 'Error - check implementation';
+
+async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) {
+  let prosemirrorDocument;
+  try {
+    const { document } = await deserializer.deserialize({ schema, content: markdown });
+    prosemirrorDocument = document;
+  } catch (e) {
+    const errorMsg = `${IMPLEMENTATION_ERROR_MSG}:\n${e.message}`;
+    return {
+      html: errorMsg,
+      json: errorMsg,
+    };
+  }
+
+  const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(
+    prosemirrorDocument.content,
+  );
+  const htmlString = documentFragment.firstChild.outerHTML;
+
+  const json = prosemirrorDocument.toJSON();
+  const jsonString = JSON.stringify(json, null, 2);
+  return { html: htmlString, json: jsonString };
+}
+
+export function renderHtmlAndJsonForAllExamples(markdownExamples) {
+  const { schema } = tiptapEditor;
+  const deserializer = createMarkdownDeserializer();
+  const exampleNames = Object.keys(markdownExamples);
+
+  return exampleNames.reduce(async (promisedExamples, exampleName) => {
+    const markdown = markdownExamples[exampleName];
+    const htmlAndJson = await renderMarkdownToHTMLAndJSON(markdown, schema, deserializer);
+    const examples = await promisedExamples;
+    examples[exampleName] = htmlAndJson;
+    return examples;
+  }, Promise.resolve({}));
+}