diff --git a/tests/src/Kernel/CustomElementBaseRenderTest.php b/tests/src/Kernel/CustomElementBaseRenderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fcf5b6aeddce13e2af16f0a7349b2c127c47a0f1
--- /dev/null
+++ b/tests/src/Kernel/CustomElementBaseRenderTest.php
@@ -0,0 +1,461 @@
+<?php
+
+namespace Drupal\Tests\custom_elements\Kernel;
+
+use Drupal\Core\Render\RendererInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\custom_elements\CustomElement;
+
+/**
+ * Tests custom elements output generation directly from CustomElement classes.
+ *
+ * This is the base test that other 'render tests' can rely on. It contains
+ * - some assertions on standalone CustomElement objects, though not as
+ *   separate test methods. Since CustomElement is just a value object, this is
+ *   not the most interesting part. (These could be extracted into a separate
+ *   unit test file, if needed.)
+ * - tests for rendering CustomElement structures into all supported output
+ *   formats.
+ *
+ * JSON output needs CustomElementNormalizer, so this tests the combination of
+ * CustomElement + CustomElementNormalizer classes.
+ *
+ * Not all details of CustomElementNormalizer have test coverage yet.
+ *
+ * @group custom_elements
+ */
+class CustomElementBaseRenderTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['custom_elements'];
+
+  /**
+   * The main custom element instance under test.
+   *
+   * @var \Drupal\custom_elements\CustomElement
+   */
+  protected CustomElement $customElement;
+
+  /**
+   * The custom elements normalizer service.
+   *
+   * @var \Drupal\custom_elements\CustomElementNormalizer
+   */
+  protected $normalizer;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->customElement = CustomElement::create('teaser-listing');
+    $this->normalizer = $this->container->get('custom_elements.normalizer');
+  }
+
+  /* -------------------------------------------------------------------------
+   *  Test Helpers
+   * ---------------------------------------------------------------------- */
+
+  /**
+   * Renders a custom element to HTML string.
+   */
+  protected function renderCustomElementToString(CustomElement $element): string {
+    $render_array = $element->toRenderArray();
+    return (string) $this->container->get(RendererInterface::class)
+      ->renderInIsolation($render_array);
+  }
+
+  /**
+   * Asserts two HTML strings are equivalent after normalization.
+   */
+  protected function assertHtmlEquals(string $expected, string $actual, string $message = ''): void {
+    $this->assertSame(
+      $this->normalizeHtmlWhitespace($expected),
+      $this->normalizeHtmlWhitespace($actual),
+      $message ?: 'Rendered HTML should match expected output'
+    );
+  }
+
+  /**
+   * Normalizes HTML whitespace for consistent comparisons.
+   */
+  protected function normalizeHtmlWhitespace(string $html): string {
+    $html = preg_replace("/ *\n */m", "", $html);
+    return preg_replace("/> +</", "><", $html);
+  }
+
+  /**
+   * Tests rendering for both Vue versions.
+   */
+  protected function runVueVersionTests(CustomElement $element, array $expected): void {
+    foreach (['vue-2' => FALSE, 'vue-3' => TRUE] as $version => $isVue3) {
+      $this->config('custom_elements.settings')->set('markup_style', $version)->save();
+
+      $output = $this->renderCustomElementToString($element);
+      $expectedHtml = $isVue3 ? $expected['vue3'] : $expected['vue2'];
+
+      $this->assertHtmlEquals(
+        $expectedHtml,
+        $output,
+        "HTML output mismatch for $version rendering"
+      );
+    }
+  }
+
+  /* -------------------------------------------------------------------------
+   *  Test Cases
+   * ---------------------------------------------------------------------- */
+
+  /**
+   * Tests basic element creation with attributes only.
+   */
+  public function testBasicElementWithAttributesWithoutChildrenOrSlots(): void {
+    // Configure element with attributes.
+    $this->customElement
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('tags', ['news', 'breaking']);
+
+    // Verify element configuration.
+    $this->assertEquals(
+      'teaser-listing',
+      $this->customElement->getTag(),
+      'Element tag name should match constructor value'
+    );
+    $this->assertEquals(
+      'Latest news',
+      $this->customElement->getAttribute('title'),
+      'Title attribute should be stored correctly'
+    );
+    $this->assertEquals(
+      ['news', 'breaking'],
+      $this->customElement->getAttribute('tags'),
+      'Array attributes should be stored correctly'
+    );
+    $this->assertEmpty(
+      $this->customElement->getSlots(),
+      'New element should not have any slots'
+    );
+
+    // Test Vue version rendering differences.
+    $this->runVueVersionTests($this->customElement, [
+      'vue2' => '<teaser-listing title="Latest news" tags="[&quot;news&quot;,&quot;breaking&quot;]"></teaser-listing>',
+      'vue3' => '<teaser-listing title="Latest news" :tags="[&quot;news&quot;,&quot;breaking&quot;]"></teaser-listing>',
+    ]);
+
+    // Verify JSON normalization.
+    $normalized = $this->normalizer->normalize($this->customElement);
+    $this->assertEquals(
+      [
+        'element' => 'teaser-listing',
+        'title' => 'Latest news',
+        'tags' => ['news', 'breaking'],
+      ],
+      $normalized,
+      'Normalized JSON should match expected structure'
+    );
+  }
+
+  /**
+   * Tests element with a single custom element child in a slot.
+   */
+  public function testSingleSlotWithCustomElementChild(): void {
+    $child = CustomElement::create('article-teaser')
+      ->setAttribute('href', 'https://example.com/news/1')
+      ->setAttribute('excerpt', 'Breaking news');
+
+    // setSlot() without $index argument explicity marks the slot as having a
+    // single value. addSlot() or setSlot() with an index do not do this,
+    // meaning the slot stays an array value.
+    $random_index = 5;
+    $element1 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->setSlot('teasers', $child);
+    $element2 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->setSlot('teasers', $child, 'div', [], $random_index);
+    $element3 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->addSlot('teasers', $child);
+
+    // Verify slot configuration.
+    foreach ([$element1, $element2, $element3] as $index => $element) {
+      // Quickly hardcoded: only $element2 has index 5 populated.
+      $slot_index = $index == 1 ? $random_index : 0;
+      $slots = $element->getSlots();
+      $this->assertArrayHasKey(
+        'teasers',
+        $slots,
+        'Element should have slot with specified name'
+      );
+      $this->assertCount(
+        1,
+        $slots['teasers'],
+        'Slot should contain exactly one entry'
+      );
+      $this->assertInstanceOf(
+        CustomElement::class,
+        $slots['teasers'][$slot_index]['content'],
+        'Slot content should be a CustomElement instance'
+      );
+      $this->assertEquals(
+        'Breaking news',
+        $slots['teasers'][$slot_index]['content']->getAttribute('excerpt'),
+        'Child element should maintain configured attributes'
+      );
+    }
+
+    // Markup output does not differ for 'single value' slots.
+    $expected = [
+      'vue2' => '<teaser-listing title="Latest news" icon="news">
+    <article-teaser href="https://example.com/news/1" excerpt="Breaking news" slot="teasers"></article-teaser>
+  </teaser-listing>',
+      'vue3' => '<teaser-listing title="Latest news" icon="news">
+    <template #teasers>
+      <article-teaser href="https://example.com/news/1" excerpt="Breaking news" slot="teasers"></article-teaser>
+    </template>
+  </teaser-listing>',
+    ];
+    $this->runVueVersionTests($element1, $expected);
+    $this->runVueVersionTests($element2, $expected);
+    $this->runVueVersionTests($element3, $expected);
+
+    // JSON output differs for 'single value' slots.
+    $singleValueTeaser = [
+      'element' => 'teaser-listing',
+      'title' => 'Latest news',
+      'icon' => 'news',
+      'teasers' => [
+        'element' => 'article-teaser',
+        'href' => 'https://example.com/news/1',
+        'excerpt' => 'Breaking news',
+      ],
+    ];
+    $arrayTeaser = [
+      'element' => 'teaser-listing',
+      'title' => 'Latest news',
+      'icon' => 'news',
+      'teasers' => [
+        [
+          'element' => 'article-teaser',
+          'href' => 'https://example.com/news/1',
+          'excerpt' => 'Breaking news',
+        ],
+      ],
+    ];
+    $this->assertEquals($singleValueTeaser, $this->normalizer->normalize($element1), 'Normalized JSON should preserve nested element structure');
+    $this->assertEquals($arrayTeaser, $this->normalizer->normalize($element2), 'Normalized JSON should preserve nested element structure');
+    $this->assertEquals($arrayTeaser, $this->normalizer->normalize($element3), 'Normalized JSON should preserve nested element structure');
+  }
+
+  /**
+   * Tests element with raw HTML content in a slot.
+   */
+  public function testSlotWithHtmlContent(): void {
+    $markup = '<p>Introduction content</p>';
+
+    // Test addSlot() vs setSlot() with similar setup as above.
+    $random_index = 5;
+    $element1 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->setSlot('introduction', $markup);
+    $element2 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->setSlot('introduction', $markup, 'div', [], $random_index);
+    $element3 = CustomElement::create('teaser-listing')
+      ->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->addSlot('introduction', $markup);
+
+    // Verify slot configuration.
+    foreach ([$element1, $element2, $element3] as $index => $element) {
+      // Quickly hardcoded: only $element2 has index 5 populated.
+      $slot_index = $index == 1 ? $random_index : 0;
+      $slots = $element->getSlots();
+      $this->assertArrayHasKey(
+        'introduction',
+        $slots,
+        'Element should have slot for HTML content'
+      );
+      $this->assertCount(
+        1,
+        $slots['introduction'],
+        'HTML slot should contain single entry'
+      );
+      $this->assertEquals(
+        $markup,
+        (string) $slots['introduction'][$slot_index]['content'],
+        'Slot content should preserve raw HTML'
+      );
+    }
+
+    // Markup output does not differ for 'single value' slots.
+    $expected = [
+      'vue2' => '<teaser-listing title="Latest news" icon="news">'
+      . '<div slot="introduction"><p>Introduction content</p></div>'
+      . '</teaser-listing>',
+      'vue3' => '<teaser-listing title="Latest news" icon="news">'
+      . '<template #introduction><p>Introduction content</p></template>'
+      . '</teaser-listing>',
+    ];
+    $this->runVueVersionTests($element1, $expected);
+    $this->runVueVersionTests($element2, $expected);
+    $this->runVueVersionTests($element3, $expected);
+
+    // JSON output differs for 'single value' slots.
+    $singleValueIntro = [
+      'element' => 'teaser-listing',
+      'title' => 'Latest news',
+      'icon' => 'news',
+      'introduction' => $markup,
+    ];
+    $arrayIntro = [
+      'element' => 'teaser-listing',
+      'title' => 'Latest news',
+      'icon' => 'news',
+      'introduction' => [$markup],
+    ];
+    $this->assertEquals($singleValueIntro, $this->normalizer->normalize($element1), 'Normalized JSON should include raw HTML content');
+    $this->assertEquals($arrayIntro, $this->normalizer->normalize($element2), 'Normalized JSON should include raw HTML content');
+    $this->assertEquals($arrayIntro, $this->normalizer->normalize($element3), 'Normalized JSON should include raw HTML content');
+  }
+
+  /**
+   * Tests slot operations with different method call orders.
+   */
+  public function testSlotOperationsWithMultipleChildren(): void {
+    // Create test elements.
+    $child1 = CustomElement::create('test-element')->setAttribute('id', '1');
+    $child2 = CustomElement::create('test-element')->setAttribute('id', '2');
+    $child3 = CustomElement::create('test-element')->setAttribute('id', '3');
+
+    $element1 = CustomElement::create('test-container');
+    $element1->addSlot('main', $child1)
+      ->addSlot('main', $child2)
+      ->addSlot('main', $child3);
+    // Test setSlot() without $index, combined with other addSlot() / setSlot()
+    // calls; see behavior below.
+    $element2 = CustomElement::create('test-container');
+    $element2->setSlot('main', $child1)
+      ->addSlot('main', $child2)
+      ->addSlot('main', $child3);
+
+    // Verify slot content counts.
+    $this->assertCount(3, $element1->getSlots()['main'], 'setSlot sequence should have 3 elements');
+    $this->assertCount(3, $element2->getSlots()['main'], 'addSlot sequence should have 3 elements');
+
+    // Markup output does not differ for 'single value' slots.
+    $expected = [
+      'vue2' => '<test-container>
+  <test-element id="1" slot="main"></test-element>
+  <test-element id="2" slot="main"></test-element>
+  <test-element id="3" slot="main"></test-element>
+</test-container>',
+      'vue3' => '<test-container>
+  <template #main>
+    <test-element id="1" slot="main"></test-element>
+    <test-element id="2" slot="main"></test-element>
+    <test-element id="3" slot="main"></test-element>
+  </template>
+</test-container>',
+    ];
+    $this->runVueVersionTests($element1, $expected);
+
+    // JSON output contains only the 'single value' slot; all slots added (with
+    // the same slot name) after the setSlot() are ignored.
+    $singleSlotValue = [
+      'element' => 'test-container',
+      'main' => ['element' => 'test-element', 'id' => '1'],
+    ];
+    $allSlotValues = [
+      'element' => 'test-container',
+      'main' => [
+        ['element' => 'test-element', 'id' => '1'],
+        ['element' => 'test-element', 'id' => '2'],
+        ['element' => 'test-element', 'id' => '3'],
+      ],
+    ];
+    $this->assertEquals($allSlotValues, $this->normalizer->normalize($element1), 'JSON mismatch');
+    $this->assertEquals($singleSlotValue, $this->normalizer->normalize($element2), 'JSON mismatch with expected single-value slot output');
+  }
+
+  /**
+   * Tests complex element with multiple slots containing various children.
+   */
+  public function testMultipleSlotsWithVariousChildren(): void {
+    // Create test elements.
+    $teasers = [
+      CustomElement::create('article-teaser')
+        ->setAttribute('href', 'https://example.com/news/1')
+        ->setAttribute('excerpt', 'Top story'),
+      CustomElement::create('article-teaser')
+        ->setAttribute('href', 'https://example.com/news/2')
+        ->setAttribute('excerpt', 'Secondary story'),
+    ];
+
+    $footer = CustomElement::create('content-footer')
+      ->setAttribute('text', 'More news');
+
+    // Configure parent element with multiple slots.
+    $this->customElement->setAttribute('title', 'Latest news')
+      ->setAttribute('icon', 'news')
+      ->addSlot('teasers', $teasers[0])
+      ->addSlot('teasers', $teasers[1])
+      ->setSlot('footer', $footer);
+
+    // Verify slot configuration.
+    $slots = $this->customElement->getSlots();
+    $this->assertCount(
+      2,
+      $slots['teasers'],
+      'Main content slot should contain all teasers'
+    );
+    $this->assertCount(
+      1,
+      $slots['footer'],
+      'Footer slot should contain single element'
+    );
+
+    // Test Vue version rendering differences.
+    $this->runVueVersionTests($this->customElement, [
+      'vue2' => '<teaser-listing title="Latest news" icon="news">
+    <article-teaser href="https://example.com/news/1" excerpt="Top story" slot="teasers"></article-teaser>
+    <article-teaser href="https://example.com/news/2" excerpt="Secondary story" slot="teasers"></article-teaser>
+    <content-footer text="More news" slot="footer"></content-footer>
+  </teaser-listing>',
+      'vue3' => '<teaser-listing title="Latest news" icon="news">
+    <template #teasers>
+      <article-teaser href="https://example.com/news/1" excerpt="Top story" slot="teasers"></article-teaser>
+      <article-teaser href="https://example.com/news/2" excerpt="Secondary story" slot="teasers"></article-teaser>
+    </template>
+    <template #footer>
+      <content-footer text="More news" slot="footer"></content-footer>
+    </template>
+  </teaser-listing>',
+    ]);
+
+    // Verify JSON normalization.
+    $normalized = $this->normalizer->normalize($this->customElement);
+    $this->assertEquals(
+      [
+        'element' => 'teaser-listing',
+        'title' => 'Latest news',
+        'icon' => 'news',
+        'teasers' => [
+          ['element' => 'article-teaser', 'href' => 'https://example.com/news/1', 'excerpt' => 'Top story'],
+          ['element' => 'article-teaser', 'href' => 'https://example.com/news/2', 'excerpt' => 'Secondary story'],
+        ],
+        'footer' => ['element' => 'content-footer', 'text' => 'More news'],
+      ],
+      $normalized,
+      'Normalized JSON should maintain separate slot structures'
+    );
+  }
+
+}