Defining Interfaces for Test Data

8 min read

You've used inline object types and type aliases for object shapes. Time to meet the canonical tool: interfaces. An interface is a named contract that says "any value claiming to be a User must have these fields, with these types." Interfaces are how you describe the shape of test data, API responses, page objects, and fixtures so the rest of your code can rely on the shape — and so the compiler catches every place that doesn't.

What an interface looks like

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "tester" | "viewer";
  isActive: boolean;
}

That's the whole syntax. interface, a name (PascalCase), and a body of propertyName: type lines. It's a description, not a value — interfaces don't exist at runtime. They're erased after compilation, just like every other type.

Use it as an annotation:

const testUser: User = {
  id: 1,
  name: "Alice",
  email: "alice@test.com",
  role: "admin",
  isActive: true,
};

Every field is enforced

The compiler checks every required field is present, has the right type, and that no extras are added.

const missing: User = {
  id: 1,
  name: "Alice",
  email: "alice@test.com",
  role: "admin",
};
// ❌ Property 'isActive' is missing in type
//    '{ id: number; name: string; email: string; role: "admin"; }'
//    but required in type 'User'.
 
const extra: User = {
  id: 1,
  name: "Alice",
  email: "alice@test.com",
  role: "admin",
  isActive: true,
  team: "QA",
};
// ❌ Object literal may only specify known properties,
//    and 'team' does not exist in type 'User'.

In JavaScript for QA you would have constructed both objects without complaint and discovered the missing field three steps later when something tried to render user.isActive. Interfaces move that whole class of bug to compile time.

Why interfaces matter for QA

Four wins compound as a test suite grows:

  1. Define the shape once, use it everywhere. Build User, TestCase, or LoginResponse once, and every helper, factory, and assertion shares the same vocabulary.
  2. Refactors become safe. Rename email to emailAddress on the interface and TypeScript walks every test file pointing at the lines that need updating. No grep, no surprises.
  3. Autocomplete that knows what's available. Type testUser. and your editor lists exactly five properties — no guessing whether the field is userName or username, isActive or active.
  4. Documentation by code. A new teammate reading interface User knows the data shape without opening a wiki. The interface is the spec.

Interfaces for API responses

API responses are a perfect fit for interfaces — they describe the contract between your tests and the system under test. A typed response means typos in field accesses fail compilation, not at midnight in CI.

interface LoginResponse {
  token: string;
  expiresIn: number;
}
 
interface ApiResponse<T> {
  status: number;
  data: T;
  message: string;
}
 
async function login(email: string, password: string): Promise<ApiResponse<LoginResponse>> {
  const res = await fetch("/api/login", {
    method: "POST",
    body: JSON.stringify({ email, password }),
  });
  return res.json();
}
 
const result = await login("alice@test.com", "secret");
console.log(result.data.token);     // ✅ autocompletes through .data → .token
console.log(result.data.tokn);
// ❌ Property 'tokn' does not exist on type 'LoginResponse'.

The <T> syntax is a generic — you'll meet generics properly in chapter 5. For now, read ApiResponse<LoginResponse> as "an ApiResponse where data is shaped like LoginResponse."

Interfaces for page objects

Page objects are classes that wrap a page's interactions in named methods — loginPage.fillUsername("alice") instead of raw selectors. An interface declares the contract every implementation has to honour:

interface LoginPage {
  navigate(): Promise<void>;
  fillUsername(username: string): Promise<void>;
  fillPassword(password: string): Promise<void>;
  clickSubmit(): Promise<void>;
  getErrorMessage(): Promise<string>;
}

A Cypress implementation and a Playwright implementation can both satisfy LoginPage. Tests can be written against the interface and swapped between frameworks without changing the assertions. You'll see this pattern in chapter 7.

What an interface really is

interface User { ... }
  • – Lists required fields and their types
  • – Compiler enforces every site
  • – New readers learn the shape from code
  • – Replaces stale wiki pages
  • – Rename → every caller flagged
  • – Add field → every constructor flagged
  • Type user. → see id, name, email… –
  • Typos rejected at compile –

The mental model: an interface is a contract that every value claiming to be that type has to satisfy. The compiler is the inspector who checks every assignment, every property access, every function call against the contract.

Interface vs the inline { ... } you've been writing

You've been annotating objects with inline shapes — const user: { id: number; name: string } = { ... }. Interfaces do the same job with a name attached. The benefits — reuse, refactor safety, better tooltips — only kick in once the type lives somewhere you can refer to it from. As soon as a shape is used in more than one place, promote it from inline to an interface.

⚠️ Common mistakes

  • Treating interfaces as runtime checks. Interfaces are erased at compile time. They guarantee nothing about an API response that arrives at runtime — if the server lies and returns { token: 42 } (a number), your code will run with that lie until the bad value blows something up. For untrusted input, parse the JSON as unknown and validate with a runtime checker (Zod, io-ts) before casting to the interface.
  • Adding fields to test fixtures without updating the interface. A common slip-up: extend a fixture JSON file with a new field and use it in tests, forgetting to add it to the interface. The compiler doesn't know about the new field, so accesses error out — or the fixture is loaded as any and you lose all safety. Update the interface alongside any fixture change.
  • Naming interfaces with an I prefix. Older C#-style codebases used IUser, ITestCase. Modern TypeScript convention is plain PascalCase — User, TestCase. The I prefix adds noise without information. Drop it unless your team has an explicit style guide that requires it.

🎯 Practice task

Build the interfaces for a small test runner. 20-30 minutes.

  1. In your ts-for-qa/src folder, create interfaces.ts.
  2. Define these three interfaces:
    • interface User { id: number; name: string; email: string; role: "admin" | "tester" | "viewer"; isActive: boolean }
    • interface TestCase { id: string; title: string; steps: string[]; expectedResult: string; status: "not-run" | "passed" | "failed" }
    • interface ApiResponse { status: number; data: TestCase[]; message: string }
  3. Create a sample value for each. Run with npx ts-node src/interfaces.ts.
  4. Trigger every error class. Try each break, read the error, then revert:
    • Omit isActive from your User value.
    • Add an extra team: "QA" field to your User.
    • Set role: "manager" (not in the union).
    • Set status: "pending" on a TestCase.
    • Pass an ApiResponse with a string data field.
  5. Write a function summarise(response: ApiResponse): string that returns "X of Y tests passed".
  6. Stretch: define a LoginPage interface with the methods from the lesson, then write a tiny dummy implementation as a plain object — every method just returns Promise.resolve() or Promise.resolve(""). Confirm the compiler enforces the contract and rejects an implementation that misses a method.

The next lesson covers two modifiers that shape what fields look like: ? for optional and readonly for "set once, never changed."

// tip to track lessons you complete and pick up where you left off across devices.