Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part V: The Decorator Chain

@FeatureTest(NavigationFeature) on the class. @Implements<NavigationFeature>('tocClickLoadsPage') on the method. The compiler catches typos. The scanner catches gaps. Three decorators close the chain.

The Three Decorators

The entire link between features and tests is built from three decorators and a runtime registry. Here's the complete implementation — it's 75 lines:

@FeatureTest(Feature) — Class-Level Binding

Links an entire test class to a feature:

export function FeatureTest<T extends abstract new (...args: any[]) => Feature>(
  feature: T
) {
  return function <C extends new (...args: any[]) => any>(target: C): C {
    (target as any).__feature = feature.name;
    (target as any).__featureClass = feature;

    // Backfill feature name on registry entries
    for (const ref of registry) {
      if (ref.testClass === target.name && !ref.feature) {
        ref.feature = feature.name;
      }
    }
    return target;
  };
}

Usage: @FeatureTest(NavigationFeature) on a test class. This stamps the class with the feature it tests.

@Implements<Feature>('acName') — Method-Level Binding

Links a specific test method to a specific acceptance criterion:

export function Implements<T extends Feature>(ac: keyof T & string) {
  return function (
    target: any, propertyKey: string, _descriptor: PropertyDescriptor
  ): void {
    registry.push({
      feature: '', // filled by @FeatureTest class decorator
      ac,
      testClass: target.constructor.name,
      testMethod: propertyKey,
    });
  };
}

The critical type: keyof T & string. This means ac must be a property name of the Feature class. If you write:

@Implements<NavigationFeature>('tocClickLoadsPage')  // ✓ compiles
@Implements<NavigationFeature>('typoInMethodName')    // ✗ TypeScript error!

The compiler catches the typo before you run anything. No runtime surprises.

@Exclude() — Skip Helpers

Marks non-test methods that the scanner should ignore:

export function Exclude() {
  return function (
    target: any, propertyKey: string, _descriptor: PropertyDescriptor
  ): void {
    excludedMethods.add(`${target.constructor.name}.${propertyKey}`);
  };
}

Usage: @Exclude() on helper methods like waitForReady() that set up test state but don't verify any AC.

The Runtime Registry

All three decorators write to a shared registry:

export interface RequirementRef {
  feature: string;      // Feature class name
  ac: string;           // AC method name
  testClass: string;    // Test class name
  testMethod: string;   // Test method name
}

const registry: RequirementRef[] = [];

This registry is what the compliance scanner reads — but the scanner doesn't actually use the runtime registry. It uses regex patterns to read the decorator references directly from source code (Part VI explains why).

A Real Test: Navigation

Here's how the decorators look on a real Playwright E2E test:

import { test, expect, type Page } from '@playwright/test';
import { FeatureTest, Implements, Exclude } from '../../requirements/decorators';
import { NavigationFeature } from '../../requirements/features/navigation';

@FeatureTest(NavigationFeature)
class NavigationTests {

  @Exclude()
  private async waitForReady(page: Page): Promise<void> {
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    await page.waitForTimeout(1500);
  }

  @Implements<NavigationFeature>('tocClickLoadsPage')
  async 'clicking TOC item loads page with fade transition'(
    { page }: { page: Page }
  ): Promise<void> {
    await this.waitForReady(page);
    await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
    await page.waitForTimeout(500);
    await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
    expect(page.url()).toContain('binary-wrapper');
  }

  @Implements<NavigationFeature>('backButtonRestores')
  async 'back button returns to previous page'(
    { page }: { page: Page }
  ): Promise<void> {
    await this.waitForReady(page);
    await page.click('.toc-item[data-path="content/blog/binary-wrapper.md"]');
    await page.waitForTimeout(500);
    await page.click('.toc-item[data-path="content/skills.md"]');
    await page.waitForTimeout(500);
    await page.goBack();
    await page.waitForTimeout(1000);
    await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
  }

  // ... 6 more methods, each with @Implements

  @Implements<NavigationFeature>('directUrlLoads')
  @Implements<NavigationFeature>('deepLinkLoads')
  @Implements<NavigationFeature>('bookmarkableUrl')
  async 'direct URL with hash loads correct page'(
    { page }: { page: Page }
  ): Promise<void> {
    await page.goto('/content/blog/binary-wrapper.html');
    await page.waitForLoadState('networkidle');
    await expect(page.locator('#markdown-output h1')).toContainText('BinaryWrapper');
  }
}

Notice:

  • @Exclude() on the waitForReady helper — the scanner knows to skip it
  • @Implements on each test method — one decorator per AC
  • Stacked decorators on the last method — one test verifies three ACs (direct URL, deep link, and bookmarkable URL are all proven by the same action)
  • String method names ('clicking TOC item loads page...') — Playwright-style descriptive test names

Registering with the Test Runner

The class methods need to be registered with Playwright's test() function:

const instance = new NavigationTests();
const proto = Object.getOwnPropertyNames(NavigationTests.prototype);
for (const method of proto) {
  if (method === 'constructor' || method === 'waitForReady') continue;
  test(method, async ({ page }) => {
    await (instance as unknown as Record<string, Function>)[method]!({ page });
  });
}

This boilerplate bridges the decorator-based class pattern with Playwright's functional API. It iterates over the prototype, skips the constructor and excluded helpers, and registers each method as a Playwright test.

The coversACs Pattern for Dynamic Tests

Some tests are parametrized — they generate test instances in loops. The @Implements decorator can't be applied inside a loop. For these, there's a class-level alternative:

@FeatureTest(VisualRegressionFeature)
class VisualTests {
  static readonly coversACs: (keyof VisualRegressionFeature)[] = [
    'desktopBaselinesMatch',
    'mobileBaselinesMatch',
    'themeVariantsMatch',
    'fullPageStitching',
  ];
  // ... dynamic test generation in loops
}

The compliance scanner detects both patterns:

  • Per-method @Implements<Feature>('ac') — granular
  • Class-level static coversACs array — batch

The granular pattern is preferred. The batch pattern is a pragmatic escape hatch for dynamic tests.

What the Compiler Catches

The decorator chain catches three categories of errors at compile time:

1. Typos in AC Names

@Implements<NavigationFeature>('tocClickLoadPage')
// Error: 'tocClickLoadPage' is not assignable to keyof NavigationFeature
// Did you mean 'tocClickLoadsPage'?

2. Wrong Feature Type

@Implements<SearchFeature>('tocClickLoadsPage')
// Error: 'tocClickLoadsPage' is not assignable to keyof SearchFeature

3. Missing Import

@FeatureTest(NavigationFeature) // Error if NavigationFeature not imported

What the compiler doesn't catch: missing tests. If a feature has 8 ACs and you only write 5 tests, TypeScript won't complain. That's the compliance scanner's job (Part VI).

Adopting Decorators Incrementally

You don't have to decorate every test file at once. The approach:

  1. Start with one critical feature. Pick NavigationFeature. Add @FeatureTest and @Implements to the existing navigation tests.
  2. Run the compliance scanner. See which ACs are covered, which aren't.
  3. Expand to adjacent features. Theme, search, accessibility — the tests already exist, you're just adding decorators.
  4. Reach 100% on critical features. Then expand to high, then medium.

On this project, decorating all 15 test files took about an hour. The tests already existed — the decorators are just metadata.


Previous: Part IV: Features as Abstract Classes Next: Part VI: The Compliance Scanner — a 300-line scanner that cross-references features and tests.