Resource library

QA How-To

TypeScript Decorators Test Metadata Tutorial: Create Test Metadata

Follow this TypeScript decorators test metadata tutorial to tag test methods, collect typed metadata, filter runs, and generate reliable JSON reports.

22 min read | 2,215 words

TL;DR

Create a standard method decorator that writes typed test records to `context.metadata`, then read those records from the decorated class through `Symbol.metadata`. Keep filtering, execution, and reporting in ordinary functions so the metadata remains easy to inspect and test.

Key Takeaways

  • Use standard TypeScript decorator contexts instead of legacy experimental decorator signatures.
  • Store suite metadata on context.metadata and identify records with a private symbol key.
  • Keep the decorator declarative and let a separate runner interpret tags, ownership, and risk.
  • Copy inherited metadata before adding records so a child suite cannot mutate its parent.
  • Validate filters and reports with deterministic node:test checks.
  • Treat decorator metadata as framework configuration, not as a place to hide test execution.

A TypeScript decorators test metadata tutorial should produce more than an attractive @test syntax. You need typed records that a runner can discover, filter, execute, and report without relying on fragile global side effects. This guide builds that complete path with standard TypeScript decorators and Node's built-in test runner.

The example fits into the broader architecture described in the TypeScript test framework design complete guide. Read that pillar when deciding where decorators belong relative to fixtures, assertions, reporters, configuration, and runtime boundaries.

You will create a small metadata-driven runner from scratch. The decorator only describes a method. Plain TypeScript functions handle discovery and execution, which keeps the design transparent and makes failures much easier to diagnose.

What You Will Build

By the end, you will have:

  • A standard @testCase() method decorator with typed options.
  • Metadata records for title, tags, owner, risk, and method name.
  • Safe discovery through the class Symbol.metadata property.
  • Tag, owner, and risk filters with predictable semantics.
  • An async runner that records passed, failed, and skipped outcomes.
  • JSON-ready summary output and automated verification.

Prerequisites

Use Node.js 22 LTS or a newer supported release and npm. The tutorial uses TypeScript 5.9 or a newer compatible compiler, standard decorators introduced in TypeScript 5, and node:test. Create a project and install the development dependencies:

mkdir decorator-test-metadata
cd decorator-test-metadata
npm init -y
npm install --save-dev typescript@^5.9 @types/node@^22

Add scripts to package.json:

{
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "test": "npm run build && node --test dist/**/*.test.js"
  }
}

The snippet shows only the fields you need to add or replace. Keep the package metadata generated by npm. Do not enable experimentalDecorators or emitDecoratorMetadata. Those switches select the legacy decorator model and optional design-type emission, not the standard decorator metadata used here.

Confirm the tools with node --version and npx tsc --version. Both commands should print versions, and TypeScript should report 5.9 or newer.

Step 1: Configure Standard Decorators and Metadata

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023", "ESNext.Decorators"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "rootDir": "src",
    "outDir": "dist",
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"]
}

ESNext.Decorators supplies the standard decorator context types. ES2023 supplies the current library declarations used by the example. The emitted JavaScript targets Node 22 while preserving native class behavior. NodeNext also requires .js extensions in relative TypeScript imports, because those are the extensions Node will load after compilation.

Current runtimes may not all expose Symbol.metadata. Standard decorator metadata needs that symbol to exist before a decorated class is evaluated. Add a narrow polyfill in src/polyfill.ts:

if (Symbol.metadata === undefined) {
  Object.defineProperty(Symbol, 'metadata', {
    configurable: false,
    enumerable: false,
    writable: false,
    value: Symbol('Symbol.metadata'),
  });
}

This creates only the well-known symbol used as the class metadata key. It does not implement reflection or emit runtime parameter types. Import this module before importing any decorated suite.

Verify the step: create an empty src/index.ts, run npm run build, and expect a clean exit plus dist/index.js and dist/polyfill.js. If the compiler says metadata does not exist on SymbolConstructor, confirm that ESNext.Decorators is spelled exactly and that the installed TypeScript version meets the prerequisite.

Step 2: Define the Typed Metadata Contract

Put framework types in src/contracts.ts:

export type Risk = 'low' | 'medium' | 'high';

export interface TestOptions {
  readonly title?: string;
  readonly tags?: readonly string[];
  readonly owner?: string;
  readonly risk?: Risk;
  readonly skip?: string;
}

export interface TestRecord {
  readonly methodName: string;
  readonly title: string;
  readonly tags: readonly string[];
  readonly owner: string;
  readonly risk: Risk;
  readonly skip?: string;
}

export interface SuiteMetadata {
  tests: TestRecord[];
}

export type TestMethod = () => void | Promise<void>;

export interface TestSuite {
  [methodName: string]: unknown;
}

TestOptions is convenient for authors, so most fields are optional. TestRecord is normalized for consumers, so title, tags, owner, and risk are always present. This division prevents the runner from repeating defaults or handling several shapes.

A suite uses an index signature because discovery obtains a method name at runtime. The value remains unknown until the runner verifies it is callable. That is safer than claiming every property is a test function.

Verify the step: run npx tsc --noEmit. Then temporarily set risk: 'critical' in a value declared as TestOptions; TypeScript should reject it. Remove the temporary value and rerun the command for a clean result.

Step 3: Create the TypeScript Decorators Test Metadata Tutorial Decorator

Create src/test-case.ts:

import type { SuiteMetadata, TestMethod, TestOptions } from './contracts.js';

const suiteMetadataKey = Symbol('qa.test.metadata');

function normalizeTag(tag: string): string {
  return tag.trim().toLowerCase();
}

export function testCase(options: TestOptions = {}) {
  return function (
    _value: TestMethod,
    context: ClassMethodDecoratorContext<object, TestMethod>,
  ): void {
    if (context.private || context.static) {
      throw new TypeError('@testCase supports public instance methods only');
    }

    const inherited = context.metadata[suiteMetadataKey] as SuiteMetadata | undefined;
    const metadata: SuiteMetadata = inherited === undefined
      ? { tests: [] }
      : { tests: [...inherited.tests] };

    const methodName = String(context.name);
    const tags = [...new Set((options.tags ?? []).map(normalizeTag).filter(Boolean))];

    metadata.tests.push(Object.freeze({
      methodName,
      title: options.title?.trim() || methodName,
      tags: Object.freeze(tags),
      owner: options.owner?.trim() || 'unowned',
      risk: options.risk ?? 'medium',
      ...(options.skip === undefined ? {} : { skip: options.skip }),
    }));

    context.metadata[suiteMetadataKey] = metadata;
  };
}

export function readTestMetadata(suite: Function): readonly import('./contracts.js').TestRecord[] {
  const metadata = suite[Symbol.metadata]?.[suiteMetadataKey] as SuiteMetadata | undefined;
  return metadata === undefined ? [] : [...metadata.tests];
}

A standard method decorator receives the original method and a ClassMethodDecoratorContext. This decorator does not replace the method, so _value is intentionally unused. It writes a normalized record to context.metadata under a private symbol, avoiding collisions with other decorators.

The copy of inherited.tests matters. A subclass can inherit its parent's metadata object through the metadata prototype chain. Mutating the inherited array would make child tests appear on the parent. Copy-on-write preserves suite isolation.

Verify the step: run npm run build. Expect no errors. Temporarily decorate a private method in the next step and run the built module; class evaluation should throw the explicit TypeError. Restore the public method after confirming the guard.

Step 4: Annotate a Test Suite

Create src/checkout-suite.ts:

import { testCase } from './test-case.js';

export class CheckoutSuite {
  @testCase({
    title: 'guest completes card checkout',
    tags: ['smoke', 'checkout'],
    owner: 'payments',
    risk: 'high',
  })
  async guestCheckout(): Promise<void> {
    const orderStatus = 'confirmed';
    if (orderStatus !== 'confirmed') {
      throw new Error(`Unexpected order status: ${orderStatus}`);
    }
  }

  @testCase({
    title: 'expired card shows a validation error',
    tags: ['regression', 'checkout'],
    owner: 'payments',
    risk: 'medium',
  })
  expiredCard(): void {
    const message = 'Card expired';
    if (!message.includes('expired')) {
      throw new Error('Expected an expired card message');
    }
  }

  @testCase({
    tags: ['regression'],
    owner: 'accounts',
    risk: 'low',
    skip: 'Waiting for the account sandbox',
  })
  savedAddress(): void {
    throw new Error('A skipped test must not execute');
  }
}

The method body remains ordinary TypeScript. The decorator adds description and scheduling facts but does not wrap assertions, catch errors, or contact a reporter. That separation means a developer can call the method directly in a focused unit test.

Prefer domain tags such as checkout, billing, or accessibility, plus a small number of execution tags such as smoke. Avoid encoding every environment and browser combination as a tag. Those values usually belong in runner configuration.

Verify the step: replace src/index.ts with the following inspection script:

import './polyfill.js';
const [{ CheckoutSuite }, { readTestMetadata }] = await Promise.all([
  import('./checkout-suite.js'),
  import('./test-case.js'),
]);

console.table(readTestMetadata(CheckoutSuite));

Run npm run build && npm start. Expect three rows. The first row should include the guest checkout title, payments, and high; the saved address row should include its skip reason. Dynamic imports ensure the polyfill executes before the decorated class module.

Step 5: Filter Discovered Tests

Filtering is a runner concern, not a decorator concern. Create src/filter.ts:

import type { Risk, TestRecord } from './contracts.js';

export interface TestFilter {
  readonly tags?: readonly string[];
  readonly owner?: string;
  readonly risk?: Risk;
}

export function filterTests(
  tests: readonly TestRecord[],
  filter: TestFilter,
): TestRecord[] {
  const wantedTags = new Set(
    (filter.tags ?? []).map((tag) => tag.trim().toLowerCase()),
  );

  return tests.filter((test) => {
    const hasTags = [...wantedTags].every((tag) => test.tags.includes(tag));
    const hasOwner = filter.owner === undefined || test.owner === filter.owner;
    const hasRisk = filter.risk === undefined || test.risk === filter.risk;
    return hasTags && hasOwner && hasRisk;
  });
}

This implementation uses AND semantics: a test must contain every requested tag. That is a useful default for commands such as smoke plus checkout. If your CLI supports OR semantics, name that mode explicitly and test both behaviors. Silent ambiguity in tag filters causes confusing differences between local and CI runs.

Metadata field Good use Poor use
tags Capability and suite selection Arbitrary issue IDs
owner Team routing and triage A person's temporary name
risk Stable business impact Current failure frequency
skip Explicit reason for not running Hiding a flaky test forever
title Human-readable report label Repeating implementation details

Verify the step: add src/filter.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import './polyfill.js';

test('filters by all requested tags', async () => {
  const [{ CheckoutSuite }, { readTestMetadata }, { filterTests }] = await Promise.all([
    import('./checkout-suite.js'),
    import('./test-case.js'),
    import('./filter.js'),
  ]);
  const selected = filterTests(readTestMetadata(CheckoutSuite), {
    tags: ['SMOKE', 'checkout'],
  });
  assert.deepEqual(selected.map((item) => item.methodName), ['guestCheckout']);
});

Run npm test. Expect one passing test and no failures. The uppercase input also proves filter normalization matches decorator normalization.

Step 6: Execute Methods and Capture Typed Results

Model runner outcomes with a discriminated union in src/runner.ts:

import type { TestRecord, TestSuite } from './contracts.js';

export type TestResult =
  | { readonly status: 'passed'; readonly test: TestRecord; readonly durationMs: number }
  | { readonly status: 'failed'; readonly test: TestRecord; readonly durationMs: number; readonly error: Error }
  | { readonly status: 'skipped'; readonly test: TestRecord; readonly reason: string };

function toError(value: unknown): Error {
  return value instanceof Error ? value : new Error(String(value));
}

export async function runTests(
  suite: TestSuite,
  tests: readonly TestRecord[],
): Promise<TestResult[]> {
  const results: TestResult[] = [];

  for (const test of tests) {
    if (test.skip !== undefined) {
      results.push({ status: 'skipped', test, reason: test.skip });
      continue;
    }

    const method = suite[test.methodName];
    if (typeof method !== 'function') {
      results.push({
        status: 'failed',
        test,
        durationMs: 0,
        error: new TypeError(`${test.methodName} is not callable`),
      });
      continue;
    }

    const startedAt = performance.now();
    try {
      await Reflect.apply(method, suite, []);
      results.push({ status: 'passed', test, durationMs: performance.now() - startedAt });
    } catch (error: unknown) {
      results.push({
        status: 'failed',
        test,
        durationMs: performance.now() - startedAt,
        error: toError(error),
      });
    }
  }

  return results;
}

Reflect.apply preserves this, which matters when suite methods use instance fields. The runtime callable check protects the boundary between a metadata string and an object property. The union makes callers narrow on status before reading error or reason. For a deeper version of this pattern, follow the TypeScript discriminated unions test results tutorial.

Verify the step: add src/runner.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import './polyfill.js';

test('runs active tests and records skips', async () => {
  const [{ CheckoutSuite }, { readTestMetadata }, { runTests }] = await Promise.all([
    import('./checkout-suite.js'),
    import('./test-case.js'),
    import('./runner.js'),
  ]);
  const suite = new CheckoutSuite();
  const results = await runTests(suite, readTestMetadata(CheckoutSuite));
  assert.equal(results.filter((result) => result.status === 'passed').length, 2);
  assert.equal(results.filter((result) => result.status === 'skipped').length, 1);
  assert.equal(results.filter((result) => result.status === 'failed').length, 0);
});

Run npm test. Expect two passing node:test tests. Change the expected checkout status in the suite to force a failure and confirm the result count changes, then restore it.

Step 7: Generate a JSON-Ready Report

Reports should not serialize raw Error objects because their useful properties are not enumerable. Create src/report.ts:

import type { TestResult } from './runner.js';

export interface RunSummary {
  readonly totals: { passed: number; failed: number; skipped: number };
  readonly tests: readonly object[];
}

export function summarize(results: readonly TestResult[]): RunSummary {
  const totals = { passed: 0, failed: 0, skipped: 0 };
  const tests = results.map((result) => {
    totals[result.status] += 1;
    const base = {
      title: result.test.title,
      owner: result.test.owner,
      risk: result.test.risk,
      tags: result.test.tags,
      status: result.status,
    };

    if (result.status === 'failed') {
      return { ...base, durationMs: result.durationMs, error: result.error.message };
    }
    if (result.status === 'skipped') {
      return { ...base, reason: result.reason };
    }
    return { ...base, durationMs: result.durationMs };
  });

  return { totals, tests };
}

Update src/index.ts to perform discovery, selection, execution, and reporting:

import './polyfill.js';
const [suiteModule, metadataModule, filterModule, runnerModule, reportModule] =
  await Promise.all([
    import('./checkout-suite.js'),
    import('./test-case.js'),
    import('./filter.js'),
    import('./runner.js'),
    import('./report.js'),
  ]);

const selected = filterModule.filterTests(
  metadataModule.readTestMetadata(suiteModule.CheckoutSuite),
  { tags: process.argv.slice(2) },
);
const results = await runnerModule.runTests(new suiteModule.CheckoutSuite(), selected);
console.log(JSON.stringify(reportModule.summarize(results), null, 2));
process.exitCode = results.some((result) => result.status === 'failed') ? 1 : 0;

Verify the step: run npm run build && npm start -- smoke. Expect totals with one passed, zero failed, and zero skipped. Run npm start -- regression and expect one passed plus one skipped. The process should exit with code 0 for both commands.

Step 8: Verify the TypeScript Decorators Test Metadata Tutorial With Inheritance

Inheritance is the easiest place for decorator metadata to become misleading. Add a focused check to src/metadata.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';
import './polyfill.js';

test('a child suite does not mutate parent metadata', async () => {
  const { testCase, readTestMetadata } = await import('./test-case.js');

  class ParentSuite {
    @testCase({ tags: ['parent'] }) parentTest(): void {}
  }
  class ChildSuite extends ParentSuite {
    @testCase({ tags: ['child'] }) childTest(): void {}
  }

  assert.deepEqual(
    readTestMetadata(ParentSuite).map((item) => item.methodName),
    ['parentTest'],
  );
  assert.deepEqual(
    readTestMetadata(ChildSuite).map((item) => item.methodName).sort(),
    ['childTest', 'parentTest'],
  );
});

The child intentionally discovers both inherited and own tests. The parent must still expose only its own record. If your framework does not allow inherited tests, change discovery to read only an own metadata property, then make that policy explicit in tests and documentation.

Verify the step: run npm test. Expect three passing test files and no failures. Then run npm start -- checkout and expect two passed tests. This completes the runnable metadata pipeline.

Troubleshooting

Problem: Symbol.metadata is undefined -> Import the polyfill before dynamically importing decorated classes. A normal static import of both modules does not guarantee that code in the importing module runs before its dependencies are evaluated.

Problem: TypeScript reports a legacy decorator signature error -> Remove experimentalDecorators and confirm the decorator uses (value, context). Legacy decorators receive target, property key, and a descriptor, which is a different API.

Problem: context.metadata has an unsafe or missing type -> Include ESNext.Decorators in lib and use a current TypeScript compiler. Do not cast the entire decorator context to any.

Problem: child tests appear in the parent suite -> Copy the inherited metadata array before adding a child record. Never push directly into an object obtained through the metadata prototype chain.

Problem: the method loses access to this -> Invoke it with Reflect.apply(method, suite, []) or method.call(suite). Extracting and calling the function without a receiver changes its this value.

Problem: a report shows {} for errors -> Map Error to explicit fields such as name, message, and a controlled stack value. Native error properties are not reliably included by JSON.stringify.

Best Practices and Common Mistakes

Do make decorators declarative. They should collect stable facts about a test, validate obvious authoring errors, and finish. Let fixtures create dependencies, let the runner schedule work, and let reporters format outcomes.

Do use a private symbol for each metadata namespace. A string key can collide with another library. Return copies or readonly views from discovery so consumers cannot silently corrupt the class metadata.

Do normalize tags once and document filter semantics. Add contract tests for case, whitespace, duplicates, empty tags, multiple requested tags, and a selection that matches nothing.

Do not confuse standard decorator metadata with reflect-metadata. The latter is commonly associated with legacy decorator type metadata and dependency injection. This tutorial needs neither emitDecoratorMetadata nor implicit runtime design types.

Do not put browser objects, secrets, environment state, or large fixture payloads into class metadata. Metadata lives as long as the class and should remain small, serializable in meaning, and safe to display. For untrusted external configuration, add runtime validation for TypeScript test data with Zod.

Do not create decorator magic for abstractions that ordinary composition expresses more clearly. For browser models, TypeScript generics for page component models often provide safer reuse than class registration side effects.

Where To Go Next

Place this registry behind a narrow discovery interface in the complete TypeScript test framework design guide. Then extend one boundary at a time:

Start with tags and ownership only. Add metadata fields when a real runner, reporter, or triage workflow consumes them. Every field becomes part of your framework contract and needs normalization, documentation, and tests.

Interview Questions and Answers

Q: What is the difference between standard and legacy TypeScript decorators?

Standard decorators use a value plus a typed context object and support the current JavaScript decorators model. Legacy decorators use target, key, and descriptor patterns behind experimentalDecorators. Their metadata and emit behavior are not interchangeable.

Q: Why store test records on context.metadata?

It associates metadata with the class without a process-wide registry. Discovery can inspect the class directly, and separate class definitions remain isolated. A symbol key prevents namespace collisions.

Q: Why should a test decorator avoid executing or wrapping the test?

Execution needs runtime context such as fixtures, retries, workers, and cancellation. Keeping the decorator declarative separates class-definition time from run time and makes both pieces independently testable.

Q: How do you prevent inherited metadata from mutating a parent suite?

Treat inherited metadata as immutable and copy its records before adding a child record. Then assign the new metadata object to the child's decorator context. Verify both parent and child discovery in a test.

Q: Why is the discovered method checked with typeof?

The method name comes from runtime metadata and indexes an object dynamically. Static types cannot prove that the property still exists or remains callable, so the runner validates that boundary before invoking it.

Q: How would you integrate this pattern with Playwright Test?

I would translate metadata into Playwright-supported annotations, tags, or wrapper configuration at test declaration time, while leaving Playwright responsible for fixtures and execution. I would avoid building a second scheduler on top of Playwright.

Q: When should you not use decorators for test metadata?

Avoid them when simple test options or a data table are clearer, or when the toolchain still relies on incompatible legacy transforms. Decorators should remove repeated declaration noise, not hide control flow.

Conclusion

This TypeScript decorators test metadata tutorial creates a complete, testable path from @testCase to filtered JSON results. Standard decorator contexts hold normalized records, while plain functions discover, select, invoke, and report tests.

Keep the metadata small and declarative. Test inheritance and runtime boundaries explicitly, then integrate the design with an existing runner instead of replacing mature scheduling, fixtures, and reporting without a concrete need.

Interview Questions and Answers

How do standard TypeScript method decorators represent test metadata?

A standard method decorator receives the method value and a `ClassMethodDecoratorContext`. It can normalize a test record and store it in `context.metadata` under a private symbol. Discovery later reads that symbol-keyed record from the class `Symbol.metadata` object.

Why use a symbol as the metadata key?

A symbol provides identity-based namespacing. It prevents the test framework's records from colliding with metadata written by another decorator library or application feature. Keeping the symbol private also forces consumers through the supported discovery function.

What is the inheritance risk with decorator metadata?

A child metadata object can see parent metadata through its prototype chain. If the decorator pushes into an inherited array, it mutates the parent's visible tests. I use copy-on-write and verify that parent and child discovery remain isolated.

Why keep execution outside a test metadata decorator?

Decorators run at class-definition time, while execution depends on runtime fixtures, workers, retries, cancellation, and environment configuration. A declarative decorator is easier to inspect and test. A separate runner can evolve without changing authoring syntax.

How do you safely invoke a discovered test method?

I read the property by its recorded method name, verify `typeof method === 'function'`, and invoke it with the suite as its receiver using `Reflect.apply`. I catch `unknown`, normalize it to `Error`, and return a typed outcome rather than assuming every thrown value is an error object.

What is the difference between decorator metadata and emitDecoratorMetadata?

Standard decorator metadata is an extensible object available through the decorator context and `Symbol.metadata`. `emitDecoratorMetadata` is a legacy TypeScript feature that emits design-type information for legacy decorators. They solve different problems and should not be treated as interchangeable.

How would you test a metadata-driven test registry?

I test normalization, duplicate handling, tag semantics, skipped records, non-callable methods, thrown non-Error values, and JSON serialization. I also create parent and child suites to prove the chosen inheritance policy. Finally, I compile strict type examples and run an end-to-end discovery-to-report test.

Frequently Asked Questions

Can TypeScript decorators store test metadata without reflect-metadata?

Yes. Standard decorators can write records through `context.metadata` and expose them on the class through `Symbol.metadata`. You may need a small `Symbol.metadata` polyfill for runtimes that do not provide the symbol.

Should I enable experimentalDecorators for standard test decorators?

No. `experimentalDecorators` selects TypeScript's legacy decorator implementation. Standard decorators use the value and context signature and do not require that compiler option.

What metadata belongs on an automated test?

Start with a human-readable title, stable domain tags, team ownership, risk, and an explicit skip reason. Add fields only when a runner, reporter, selection rule, or triage workflow consumes them.

Can a decorator access Playwright fixtures?

A decorator is evaluated when the class is defined, so it does not have a running Playwright test or its fixtures. Keep fixture access in the test body or a Playwright fixture and use the decorator only for declaration metadata.

How do I filter tests by multiple decorator tags?

Normalize both stored and requested tags, then define whether multiple tags use AND or OR semantics. The tutorial uses AND semantics, meaning a selected test must contain every requested tag.

Does TypeScript decorator metadata validate runtime configuration?

No. TypeScript types disappear at runtime, and metadata merged from JSON, environment variables, or services remains untrusted. Validate external data before converting it to your internal metadata contract.

How should inherited decorated tests behave?

Choose and document a policy. If child suites inherit parent tests, copy inherited records before adding child records so the parent remains unchanged; if inheritance is forbidden, discovery should read only own metadata.

Related Guides