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;
};
}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,
});
};
}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!@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}`);
};
}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[] = [];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');
}
}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 thewaitForReadyhelper — the scanner knows to skip it@Implementson 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 });
});
}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
}@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 coversACsarray — 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'?@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@Implements<SearchFeature>('tocClickLoadsPage')
// Error: 'tocClickLoadsPage' is not assignable to keyof SearchFeature3. Missing Import
@FeatureTest(NavigationFeature) // Error if NavigationFeature not imported@FeatureTest(NavigationFeature) // Error if NavigationFeature not importedWhat 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:
- Start with one critical feature. Pick
NavigationFeature. Add@FeatureTestand@Implementsto the existing navigation tests. - Run the compliance scanner. See which ACs are covered, which aren't.
- Expand to adjacent features. Theme, search, accessibility — the tests already exist, you're just adding decorators.
- 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.