Understand Software Testing with Vitest

Learn to read and evaluate any test in your codebase as a non-technical founder.

Introduction

30 Second Summary

Every restaurant runs quality checks before food reaches a customer. Software testing is the same concept applied to code: automated checks that verify everything works correctly, every single time.

In this project, you will build a complete mental model of software testing, grounded in real examples from your own Itezan codebase. You'll learn to read any test file in your repo, understand your testing tools, and evaluate your team's testing strategy.

What You'll Build

A comprehensive understanding of software testing, from unit tests to TDD, grounded in real examples from your Itezan food-and-beverage platform.

By the end of this project, you'll have:

  • A clear mental model of the testing pyramid and where each type of test in your codebase fits.
  • The ability to open any test file in your repo and understand its structure, purpose, and assertions.
  • A framework for evaluating your team's testing strategy and identifying where gaps might be.
  • Secret Mission: sketch a test plan for a new Itezan feature using TDD principles.

Are there any prerequisites?

You'll need basic familiarity with TypeScript syntax (reading, not writing). You'll also need access to the Itezan codebase to follow along with the real examples.

What Is a Test, and Why Not Just Click Around?

You're building a multi-tenant platform where a bug in one restaurant's subscription logic could silently cascade to others. How do you catch those breaks before your customers do?

In this step, you'll build a mental model for what automated tests actually are, why they're worth the investment, and how they're organized into layers that map directly to your Itezan codebase.

In this step, get ready to:
  • Understand what an automated test is and how it differs from manual "clicking around."
  • Learn the costs and benefits of writing tests for a production app.
  • Map the three levels of the test pyramid to concrete Itezan examples.
The restaurant inspection analogy

Think about the difference between a chef tasting their own food and a health inspector running a formal checklist. Both catch problems, but they work in very different ways.

Manual testing is the chef tasting. You click through your app, try a few flows, and eyeball whether things look right. It works, but only when someone remembers to do it.

What is an automated test?

An automated test is code that checks other code. It runs a piece of your app with known inputs and verifies the output matches what you expect.

Automated tests are the health inspector's checklist. They run every single time, even at 3am when nobody's watching. They never forget a check, never get tired, and never assume "that probably still works."

Here's what a test looks like at its simplest. This is illustrative TypeScript, not something you need to type:

// A test is just code that checks other code
const result = calculateSubscriptionEndDate('2025-03-01', 7)

// Does the result match what we expect?
expect(result).toBe('2025-03-08')

What does this code do?

It calls a function with a known start date and a 7-day duration. Then it checks whether the result equals the expected end date. If it doesn't, the test fails and alerts your team.

That's it. Every test in your codebase follows this same pattern: call something with known inputs, check the output.

The cost/benefit equation

Tests are an investment. Like any investment, they have clear benefits and real costs. Understanding both helps you make good decisions about where to invest your team's testing effort.

Why do teams write tests?

  • Regression catching. You fix the subscription bug, and tests make sure it never comes back. Every future code change re-runs the check automatically.
  • Refactoring confidence. Your team can restructure code without fear. If something breaks, the tests catch it immediately.
  • Documentation via code. A test file literally shows what the code is supposed to do. It's a living spec that can't go stale the way a wiki page can.
  • Faster debugging. A failing test points you exactly to the break. No more hunting through logs or guessing which change caused the issue.

But tests aren't free. Here are the real costs your team pays:

  • Maintenance. Tests break when you change things. A renamed field or restructured API means updating every test that touches it.
  • Time to write. Writing good tests takes real engineering time. A thorough test suite for a feature can take as long as writing the feature itself.
  • False confidence. Tests can pass while real bugs hide in untested paths. A green test suite doesn't guarantee zero bugs.

The kitchen quality checklist analogy

Think of tests like quality checklists at each kitchen station. They cost time to create and maintain, but they catch the contaminated ingredient before it reaches the customer.

For Itezan specifically, a bug in subscription billing logic could charge the wrong restaurant or skip a delivery day. That's the kind of bug you want a checklist for.

The test pyramid, grounded in Itezan

Not all tests are created equal. The test pyramid is a model that organizes tests into three layers based on speed, scope, and cost.

The pyramid shape matters. You want many fast tests at the bottom, fewer medium-speed tests in the middle, and very few slow tests at the top.

What are the three layers?

  • Unit tests (bottom layer, many). Test a single pure function in isolation. Fast, cheap, and run in milliseconds.
  • Integration tests (middle layer, fewer). Test that multiple pieces work together. Slower because they hit real databases or APIs.
  • E2E tests (top layer, very few). Test the full user journey through a real browser against the full stack. Slowest and most expensive to maintain.

Let's ground each layer in a concrete Itezan example so you can picture exactly what each level looks like.

Unit test example:

// Unit test: tests a single function with no dependencies
// Arrange: set up known inputs
const startDate = '2025-03-01'
const duration = 7

// Act: call the function
const result = calculateSubscriptionEndDate(startDate, duration)

// Assert: verify the output
expect(result).toBe('2025-03-08')

Why is this a unit test?

It tests one pure function (calculateSubscriptionEndDate) with no database, no network, no browser. Give it inputs, check the output. It runs in under a millisecond.

Your codebase has many functions like this: pricing calculations, date math, permission checks. Each one is a perfect unit test candidate.

Integration test example:

// Integration test: tests that creating a subscription
// via GraphQL correctly inserts day_orders in PostgreSQL
const response = await graphqlClient.mutate({
  mutation: CREATE_SUBSCRIPTION,
  variables: { userId: testUser.id, planId: '7-day-plan' }
})

// Verify the database state changed correctly
const dayOrders = await queryPostgres(
  'SELECT * FROM day_orders WHERE subscription_id = $1',
  [response.data.createSubscription.id]
)

expect(dayOrders).toHaveLength(7)

Why is this an integration test?

It tests that two systems work together: your GraphQL API layer and your PostgreSQL database. The mutation hits real Hasura, which inserts real rows into a real (Docker-hosted) Postgres instance.

It's slower than a unit test because it needs running infrastructure. But it catches bugs that unit tests never could, like a typo in your SQL migration or a mismatch between your GraphQL schema and your database columns.

E2E test example (described in English, not code):

  • A real browser opens your app.
  • A test user signs up with an email and password.
  • They pick a 7-day meal plan.
  • They place an order for Monday.
  • They navigate to their dashboard and verify the order appears.

Why is this an E2E test?

It tests the full user journey through a real browser against the full stack. Authentication, UI rendering, API calls, database writes, and page navigation all have to work together for this test to pass.

You only need a few of these because they're slow (seconds, not milliseconds) and brittle (a UI redesign breaks them even if the logic is fine). Reserve them for your most critical user flows.

Why the pyramid shape matters

If you flip the pyramid (many E2E tests, few unit tests), your test suite takes 20 minutes instead of 20 seconds. Developers stop running it locally, bugs sneak through, and the whole system loses trust.

The pyramid shape keeps your feedback loop fast. Most checks happen at the unit level (instant), a smaller set verifies integrations (seconds), and a handful of E2E tests guard the critical paths (minutes).

You now have the big picture: tests are code that checks code, they have real costs and benefits, and they come in three layers that trade off speed for realism. Next up, you'll zoom into the anatomy of a single test file and learn the patterns that make every test in your codebase tick.

Anatomy of a Test: Describe, It, Expect

You now have a mental map of the test pyramid and where unit, integration, and E2E tests live. The next question is: what does one of those tests actually look like when you open the file?

Every test in your Itezan codebase follows the same anatomy. Once you recognise the pattern, you can open any .test.ts file and read it like a story.

In this step, get ready to:
  • Learn the describe/it/expect structure that every test file uses.
  • Understand the Arrange-Act-Assert rhythm inside every test.
  • Understand lifecycle hooks (beforeAll, beforeEach, afterEach, afterAll) and when to use each.
The describe/it/expect pattern

Every test file in your codebase uses three building blocks: describe groups related tests together, it describes one specific behaviour, and expect checks the result.

  • Look at this example of what a unit test looks like for an Itezan subscription function:
// describe groups all the tests for one function
describe('calculateSubscriptionEndDate', () => {

  // it describes one specific behaviour
  it('returns correct end date for 7-day plan', () => {
    const startDate = new Date('2026-03-01');
    const duration = 7;

    // Call the function we're testing
    const result = calculateSubscriptionEndDate(startDate, duration);

    // expect checks the result matches what we want
    expect(result).toBe('2026-03-08');
  });

  it('returns correct end date for 30-day plan', () => {
    const startDate = new Date('2026-03-01');
    const duration = 30;

    const result = calculateSubscriptionEndDate(startDate, duration);

    expect(result).toBe('2026-03-31');
  });
});

What does this code do?

This reads like English on purpose. Read it out loud: "Describe calculateSubscriptionEndDate. It returns correct end date for 7-day plan. Expect result to be March 8."

  • describe('calculateSubscriptionEndDate') creates a labelled group. All tests for this one function live inside it.
  • it('returns correct end date for 7-day plan') is one test case. The string inside is a human-readable description of the behaviour being checked.
  • expect(result).toBe('2026-03-08') is the assertion. If the result doesn't match, the test fails and tells you exactly what went wrong.

When this test runs, Vitest will display something like ✓ calculateSubscriptionEndDate > returns correct end date for 7-day plan for a passing test. Each describe label and it label combine to form a readable sentence in the output.

Look for this pattern in your codebase

In Cursor, try searching for describe( across your project files. You'll see this exact structure in every test file. The grouping labels will match function names, component names, or feature names in your app.

Arrange-Act-Assert: the three-part rhythm

Inside every it block, the code follows a three-part rhythm called Arrange-Act-Assert. Think of it like a restaurant kitchen.

  • Arrange = mise en place. Prep your ingredients (set up the data you need).
  • Act = cook the dish. Call the function or trigger the action you're testing.
  • Assert = taste test. Check the output matches what you expect.
  • Here is the same test from above with comments marking each phase:
it('returns correct end date for 7-day plan', () => {
  // ARRANGE - mise en place: prep the ingredients
  const startDate = new Date('2026-03-01');
  const duration = 7;

  // ACT - cook the dish: call the function
  const result = calculateSubscriptionEndDate(startDate, duration);

  // ASSERT - taste test: check the result
  expect(result).toBe('2026-03-08');
});

Why does this rhythm matter?

When a test fails, the three-part structure makes it obvious where things went wrong. Was the data set up incorrectly (Arrange)? Did the function behave unexpectedly (Act)? Or is the expectation wrong (Assert)?

Every test in your Itezan codebase follows this rhythm, even when the test is 50 lines long. The sections might be bigger, but the three-phase structure stays the same.

What if a test has no clear Arrange section?

Some simple tests skip Arrange entirely. For example, testing that an error is thrown with invalid input might go straight to Act+Assert in one line: expect(() => createOrder(null)).toThrow(). That's fine. The three-part rhythm is a default, not a rigid rule.

Setup and teardown: the lifecycle hooks

Some tests need shared preparation that runs before or after them. Vitest provides four lifecycle hooks for this.

  • beforeAll runs once before all tests in a describe block. Like opening the restaurant for the day.
  • beforeEach runs before every single test. Like wiping the counter between orders.
  • afterEach runs after every single test. Cleans up so the next test starts fresh.
  • afterAll runs once after all tests finish. Like closing the kitchen at end of day.

These hooks become essential in your integration tests, where real infrastructure needs to spin up and down. Here is what that looks like:

describe('Subscription lifecycle', () => {

  // Runs ONCE - start Docker containers (Postgres, Hasura)
  beforeAll(async () => {
    await startDockerContainers();
    await applyHasuraMigrations();
  });

  // Runs before EVERY test - insert fresh test data
  beforeEach(async () => {
    testSubscriptionId = await createTestSubscription({
      userId: testUserId,
      plan: '7-day',
      startDate: '2026-03-01',
    });
  });

  // Runs after EVERY test - delete ONLY the data this test created
  afterEach(async () => {
    await deleteSubscription(testSubscriptionId);
  });

  // Runs ONCE at end - shut down Docker containers
  afterAll(async () => {
    await stopDockerContainers();
  });

  it('creates day_orders for each delivery day', async () => {
    const dayOrders = await getDayOrdersForSubscription(testSubscriptionId);
    expect(dayOrders).toHaveLength(7);
  });

  it('marks subscription as active after creation', async () => {
    const sub = await getSubscription(testSubscriptionId);
    expect(sub.status).toBe('active');
  });
});

What's happening in this integration test?

  • beforeAll starts real Docker containers. This is expensive (takes seconds), so it only happens once.
  • beforeEach creates a fresh subscription for each test. Every test gets its own clean data to work with.
  • afterEach deletes that specific subscription by its exact ID. This prevents tests from contaminating each other.
  • afterAll shuts down Docker. Cleanup is done. The machine is back to its original state.

Why delete by exact ID, not by pattern?

If cleanup deleted all records matching a pattern (like WHERE name LIKE '%test%'), it might accidentally destroy data another test created. Deleting by the exact UUID you just inserted is surgical and safe. This is why afterEach blocks in your integration tests always reference specific IDs.

Quick reference for picking the right hook

  • Expensive one-time setup (start a server, connect to a database): beforeAll.
  • Fresh data for each test (insert a row, reset a variable): beforeEach.
  • Cleanup after each test (delete created data, reset mocks): afterEach.
  • Expensive one-time teardown (stop a server, close connections): afterAll.

You now have the full anatomy of a test file: describe groups related tests, it blocks follow the Arrange-Act-Assert rhythm, and lifecycle hooks handle shared setup and cleanup. Next up, you'll see how your team's test runner (Vitest) actually finds and executes these files.

Understand Your Vitest Configuration

You now know how a test is structured: describe groups, it blocks, and expect assertions following the Arrange-Act-Assert rhythm.

But something has to actually find those test files, run them, and tell you what passed or failed. That something is the test runner. Your team uses Vitest. In this step, you'll understand what Vitest is, decode every line of your configuration file, and learn how your project runs different types of tests.

In this step, get ready to:
  • Understand what Vitest is and why your team chose it over Jest.
  • Read your vitest.config.mts file and understand every line.
  • Learn the difference between watch mode and run mode, and why unit and integration tests are split.
What Vitest is and why your team chose it

A test runner is the program that discovers your test files, executes them, and reports which passed and which failed. Think of it as the head inspector who coordinates all the individual checklist items across every station in the kitchen.

Your team uses Vitest as its test runner. Vitest was built by the same team behind Vite, the build tool that powers modern front-end development.

Why Vitest over Jest?

Jest is the most popular JavaScript test runner, and most online tutorials use it. Your team chose Vitest for three reasons:

  • Speed: Vitest has native ESM support and shares Vite's transformation pipeline. Tests start faster because there's no separate compilation step.
  • Same API: Vitest uses the exact same describe/it/expect syntax as Jest. All Jest knowledge transfers directly.
  • Native TypeScript: Vitest understands TypeScript out of the box without extra configuration or transpilation plugins.

It's like choosing a convection oven over a conventional one: same recipes, faster cooking.

Your Vitest configuration file explained

Every Vitest project has a configuration file that tells the runner how to behave. Your project's configuration lives in vitest.config.mts at the root of the codebase.

  • In Cursor, open the file explorer sidebar and find vitest.config.mts in the project root.
  • Click to open it. You should see something like this:
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    exclude: ['tests/**/*']
  }
})

What does each line do?

  • defineConfig from vitest/config: a helper that gives you autocomplete and type checking for config options.
  • plugins: [tsconfigPaths(), react()]: two plugins working together. tsconfigPaths() resolves your @/ import aliases (so @/components/Button maps to the right file). react() handles JSX transformation so your .tsx files can render in tests.
  • environment: 'jsdom': simulates a browser environment. Your components expect things like document and window to exist. jsdom fakes those browser APIs so component tests can run in Node.js without opening a real browser.
  • globals: true: lets you write describe, it, and expect without importing them at the top of every test file. They become available everywhere automatically.
  • setupFiles: ['./vitest.setup.ts']: a file that runs before every test suite. This is where global matchers (like toBeInTheDocument()) get registered and DOM cleanup happens after each test.
  • include and exclude patterns: tell Vitest which files are tests. Here, anything matching src/**/*.test.{ts,tsx} is a test file. The tests/**/* folder is excluded because integration tests have their own run command.

What is jsdom?

jsdom is a JavaScript implementation of the browser's DOM. It lets your component tests render React components and check the output without launching Chrome or Firefox.

It's like a dress rehearsal on a practice stage. You verify all the actors hit their marks without renting the actual theatre.

Your exact file might have slightly different include/exclude patterns. The important thing is that you now understand what each section controls.

Check your setup file too

If you're curious, open vitest.setup.ts next. You'll likely see it importing @testing-library/jest-dom/vitest for extra matchers and calling cleanup() from @testing-library/react in an afterEach block to reset the DOM between tests.

Running tests: watch mode, run mode, and your scripts

Now that you understand the config, let's look at how tests actually get executed. Vitest has two primary modes.

  • In Cursor, open the integrated terminal by pressing Ctrl+` (macOS and Windows).
  • Open package.json and look for the scripts section. You'll see entries for running tests.

What is watch mode vs run mode?

  • Watch mode (vitest with no flags): stays running and re-executes tests every time you save a file. It's a live dashboard showing you instantly whether your latest change broke something. This is what developers use while actively coding.
  • Run mode (vitest run): executes the full test suite once and exits. This is what CI/CD pipelines use. The pipeline needs a pass/fail result, not an open process waiting for file changes.

When you or your team runs npm run test, Vitest starts in watch mode by default.

Your project likely splits its test scripts into two separate commands. Here's why.

Why split test:unit and test:integration?

Component tests in src/ are fast. They use fake browser APIs (jsdom) and mock GraphQL responses. They finish in seconds.

Integration tests in tests/ need real infrastructure. They require Docker containers running PostgreSQL and Hasura. Starting those containers adds seconds (or minutes) of overhead.

Splitting them means developers can run test:unit in watch mode for instant feedback while coding, without needing Docker running. Then test:integration runs separately when they need to verify database interactions.

The kitchen analogy

Unit tests are like tasting a sauce at your prep station. Instant, no setup required. Integration tests are like running a full service with the pass, the waitstaff, and real customers. Important, but you don't do it every time you adjust a recipe.

You now have a complete picture of your test runner. You know what Vitest does, how it's configured, and why tests are split into fast unit tests and slower integration tests. Next up, you'll learn about mocking: the technique that lets component tests run without a real server.

Understand Mocking and Assertions

Your tests need to run fast and produce the same result every time. But your components talk to external services like Hasura and GraphQL APIs. If every component test needed a real server running, your test suite would be slow, flaky, and impossible to run in isolation.

Mocking solves this problem by replacing real dependencies with controlled fakes. In this step, you'll learn what mocking is, how your Itezan codebase uses it for component tests, and the assertion vocabulary that every test uses to declare "this is correct."

In this step, get ready to:
  • Understand what mocking is, when to use it, and where Itezan sits on the mocking spectrum.
  • Learn how vi.mock() and the MockedProvider pattern fake GraphQL in component tests.
  • Build your vocabulary of assertion methods for checking test results.
What mocking is and when to use it

Mocking means replacing a real dependency with a fake one that you control. Your component test shouldn't need a real Hasura server running just to check whether a button renders correctly.

What is mocking?

Think of it this way: when you test a kitchen's plating skills, you don't need to grow the vegetables from seed. You provide pre-cut, known ingredients so you can focus on testing one thing: the plating.

A mock does the same for code. It provides known, pre-defined data so the test can focus on one specific behavior without needing the entire real system running.

There's a spectrum of how much a team mocks. On one end, you mock everything: tests are fast and isolated, but they can be fragile because they don't catch real integration failures. On the other end, you mock nothing: tests are realistic but slow and hard to set up.

Your Itezan codebase sits in the middle of this spectrum. Component tests (in src/components/) mock GraphQL responses so they run instantly without infrastructure. Integration tests (in tests/) use real Hasura and Postgres in Docker containers to test actual database behavior.

Why not mock everything?

If you mock too aggressively, your tests can pass even when the real system is broken. Imagine testing a kitchen's food safety by using fake thermometers that always read 165°F. The test "passes" but you've learned nothing about actual temperatures.

That's why your team uses the split approach: mock for speed in component tests, use real infrastructure for correctness in integration tests.

How vi.mock() and MockedProvider work in your codebase

In Apollo Client 4, React-specific exports (like hooks) live in @apollo/client/react. When a component test calls vi.mock('@apollo/client/react'), it tells Vitest to replace the real Apollo Client hooks with fakes that return whatever data you define in the test.

Here's what that looks like conceptually:

// This replaces the REAL Apollo Client hooks with fakes
vi.mock('@apollo/client/react')

// Now when <MenuItemCard> calls useQuery(GET_MENU_ITEM),
// it won't hit Hasura. Instead, it gets back
// whatever fake data you define in the test.

What does this do?

When Vitest sees vi.mock('@apollo/client/react'), it intercepts every import from that module. Any component that calls useQuery or useMutation will get a fake version instead of the real one that talks to your server.

The other pattern you'll see is MockedProvider. In Apollo Client 4, this is imported from @apollo/client/testing/react. It wraps your component and provides pre-defined GraphQL responses.

import { MockedProvider } from '@apollo/client/testing/react'
import { render, screen } from '@testing-library/react'
import { MenuItemCard } from '@/components/MenuItemCard'
import { GET_MENU_ITEM } from '@/graphql/queries'

// Define exactly what the "server" will respond with
const mocks = [
  {
    request: {
      query: GET_MENU_ITEM,
      variables: { id: 'item-001' }
    },
    result: {
      data: {
        menu_item: {
          id: 'item-001',
          name: 'Nasi Lemak Set',
          price: 12.90,
          available: true
        }
      }
    }
  }
]

// Wrap the component so it receives fake data
render(
  <MockedProvider mocks={mocks}>
    <MenuItemCard itemId="item-001" />
  </MockedProvider>
)

// Now check that the component renders correctly
expect(screen.getByText('Nasi Lemak Set')).toBeInTheDocument()

What is MockedProvider doing here?

Each mock has a request (the query and variables the component will send) and a result (the data that comes back). The mock must exactly match the operation's shape and variables.

It's like giving a waiter a script: "When the customer asks for item-001, bring them this exact plate." The component doesn't know it's getting fake data. It behaves exactly as it would with a real server.

  • In Cursor, try searching for MockedProvider across the codebase using Cmd+Shift+F (macOS) or Ctrl+Shift+F (Windows).
  • Open any file that uses this pattern and look for the mocks array. Notice how each mock defines a specific query and its expected response.

Reading mock arrays

When you see a mock array in a test file, read it as a menu of scripted responses. Each entry says "when the component asks THIS question, give it THIS answer." The rest of the test then verifies the component handles that answer correctly.

Assertions: the vocabulary of checking

Every test ends with an assertion: a statement that says "I expect this value to equal that value." Vitest gives you a set of assertion methods, each designed for a different type of check. Here are the ones you'll see most often in the Itezan codebase.

// toBe - strict equality (same value AND same type)
// Use for: primitives like strings, numbers, booleans
expect(subscription.status).toBe('active')
expect(order.quantity).toBe(3)
expect(user.isVerified).toBe(true)

When to use toBe

Use toBe when checking a single, simple value. It's strict: toBe('3') fails if the actual value is the number 3 because a string is not the same type as a number.

// toEqual - deep equality (compares all properties inside objects/arrays)
// Use for: objects, arrays, nested data structures
expect(order).toEqual({
  id: 'order-001',
  items: ['nasi-lemak', 'teh-tarik'],
  status: 'confirmed'
})

toBe vs toEqual

Use toEqual when comparing objects or arrays. toBe checks if two things are the exact same reference in memory. toEqual checks if two things have the same content, even if they're different objects.

// toContain - checks if an array includes a value, or a string includes a substring
// Use for: checking membership in a list or presence in text
expect(user.roles).toContain('kitchen_staff')
expect(errorMessage).toContain('Invalid date')
// toThrow - checks that a function throws an error
// Use for: validating that bad input is rejected
expect(() => createOrder(invalidData)).toThrow()

// rejects.toThrow - checks that an async function rejects with an error
// Use for: async operations that should fail (API calls, DB queries)
await expect(
  api.createSubscription(badInput)
).rejects.toThrow('Invalid date')

Why the arrow function wrapper for toThrow?

Notice expect(() => createOrder(invalidData)).toThrow() wraps the call in an arrow function. Without it, the error would escape the test before expect can catch it. The wrapper lets Vitest run the function in a controlled way and check whether it throws.

For async functions, rejects.toThrow() handles promises that reject. You can optionally pass the expected error message as a string to make the check even more specific.

Here's a quick reference for choosing the right assertion:

  • Checking a simple value (string, number, boolean): use toBe.
  • Comparing objects or arrays: use toEqual.
  • Checking if something is in a list or a string: use toContain.
  • Verifying a function rejects bad input: use toThrow or rejects.toThrow.
  • In Cursor, search for .toEqual or .toContain across your test files to see real examples of these assertions in action.
  • Notice how each assertion reads like a sentence: "expect status to be active," "expect roles to contain kitchen_staff."

You now have the vocabulary to read any component test in your codebase. You understand what the mocks set up, what the assertions check, and why the team chose this approach. Next up, you'll tackle the trickiest part of testing: the database problem that integration tests have to solve.

Understand the Test Database Problem

In the previous step, you learned how mocking lets component tests fake the outside world. Component tests never touch a real database. But integration tests do the opposite. They hit real infrastructure on purpose.

That creates a tricky problem: when multiple tests share the same database, they can step on each other's data. In this step, you'll see exactly how your Itezan codebase solves this with Docker containers, surgical cleanup, and sequential execution.

In this step, get ready to:
  • Understand why shared database state makes integration tests tricky.
  • See how your Itezan tests use Docker and the Hasura run_sql endpoint to stay isolated.
  • Trace the full lifecycle of an integration test run from Docker start to cleanup.
Why integration tests are tricky

Integration tests hit real databases. That's the whole point. They verify that your code works with real PostgreSQL and real Hasura, not fakes.

But that creates a problem. Imagine Test A creates a subscription. Then Test B checks "how many subscriptions exist?" If they share the same database, Test B sees Test A's leftover data and fails.

The shared kitchen analogy

Picture two chefs sharing one prep station without communication. Chef A leaves flour on the counter. Chef B grabs it thinking it's powdered sugar. The dessert is ruined.

That's exactly what happens when tests share a database without cleanup. Each test must leave the database exactly as it found it. This principle is called test isolation.

Without isolation, you get flaky tests. Tests that pass when run alone but fail when run together. These are one of the most frustrating problems in software engineering because the failures seem random.

How your Itezan tests solve this

Your team uses three strategies working together: real infrastructure in Docker, the Hasura admin API for setup and teardown, and surgical deletion by exact ID.

  • In Cursor, open the tests/ folder in the Explorer sidebar.
  • Open tests/day-order-lifecycle.integration.test.ts.

This is a real integration test from your codebase. Let's look at how it handles the database problem.

What is Docker Compose doing here?

Your integration tests use docker compose up to spin up real Postgres and Hasura containers. This gives every test run a fresh, isolated environment that matches production. It's like having a brand new kitchen built from scratch every time you run your tests.

  • Look for the beforeAll block near the top of the file.
  • Notice how it sets up the Docker containers and seeds initial data.

What is the Hasura run_sql endpoint?

Hasura provides an admin API endpoint called run_sql that lets you execute raw SQL against the database. Your tests use this to insert test data during setup and delete it during teardown. It's a direct line to the database that bypasses all the GraphQL layer.

  • Now look for the afterEach block in the file.
  • Notice it deletes records by their exact UUID, not by a pattern like WHERE name LIKE '%test%'.

Why delete by exact UUID instead of a pattern?

If you delete WHERE name LIKE '%test%', you might accidentally delete data another test created. Deleting by the exact UUID you just inserted is surgical and safe.

Think of it like cleaning a shared kitchen. You only wash the dishes YOU used. You don't throw away everything in the fridge because some of it might be yours.

The lifecycle of an integration test run

Now that you've seen the pieces, let's trace what happens from start to finish when npm run test:integration executes.

  • Scroll back to the very top of tests/day-order-lifecycle.integration.test.ts and read through the entire file from top to bottom, following the lifecycle below.

What happens during an integration test run?

Here's the full sequence, step by step:

  • Docker containers start (Postgres, Hasura, Nhost auth). This gives you real infrastructure.
  • Hasura applies migrations. Your database schema is created fresh, matching production's structure.
  • Tests run sequentially (not in parallel). Because they share one database, running them at the same time would cause the exact interference problem we discussed.
  • Each test creates its own data, runs the behavior it's checking, then verifies the result.
  • afterEach cleanup runs after every test, deleting the specific records that test created. This runs even if the test fails, preventing contamination from cascading.

Why sequential and not parallel?

Your unit tests can run in parallel because each one is completely independent. They don't share any state. But your integration tests all talk to the same Postgres database.

Running them in parallel would be like having two chefs simultaneously trying to clean and use the same cutting board. Sequential execution is slower but guarantees each test gets a clean state.

  • Look at the individual it blocks in the file. Notice how each one follows a mini lifecycle: create data, perform an action, check the result.
  • Find the afterAll block at the bottom. This stops the Docker containers when all tests are finished.

Reading this file like a story

Try reading the test file as a narrative: "Given a subscription exists, when I create a day order for Tuesday, then the day order appears in the database with the correct fields." Each it block is one sentence in that story.

Having trouble finding these patterns?

If your file structure looks different from what's described, the test patterns may live in a helper file imported at the top. Check the imports for utility functions that handle Docker setup or database cleanup.

Help me find the beforeAll, afterEach, and afterAll blocks in my integration test files

You now understand the database isolation problem and how your team solves it. Next up, you'll explore TDD. It flips the entire development process on its head by writing tests before the code even exists.

Learn Test-Driven Development

You now understand how integration tests stay isolated by cleaning up after themselves with surgical precision. But there's a deeper question: when should you write the test in the first place?

Most people assume you write code first, then test it. Test-Driven Development flips that assumption on its head. You write the test first, watch it fail, then write just enough code to make it pass. In this step, you'll learn why that backwards-sounding approach produces better software.

In this step, get ready to:
  • Understand the red/green/refactor cycle that defines TDD.
  • Walk through a concrete TDD example using Itezan subscription logic.
  • Know when TDD is the right approach and when it's overkill.
Understand the red/green/refactor cycle

TDD is a discipline with three repeating phases. Each phase has a colour name that makes it easy to remember.

  • RED — Write a test for behavior that doesn't exist yet. Run it. It fails (red) because the code hasn't been written.
  • GREEN — Write the absolute minimum code needed to make the test pass. Nothing fancy. Just enough to turn the test result green.
  • REFACTOR — Clean up the code you just wrote. Improve naming, remove duplication, handle edge cases. The tests keep passing throughout, proving you haven't broken anything.

Then you repeat. Pick the next behavior, write a failing test, make it pass, clean up.

Why write the test first?

Think of it like writing a health inspector's checklist BEFORE you build the kitchen. The checklist forces you to define exactly what "correct" means before you start building. Without it, you build the kitchen first and then try to figure out what to check afterwards, often missing important things.

TDD forces you to think about edge cases and requirements upfront. The test is a contract: "this function MUST do X given Y." Only then do you build to fulfil that contract.

Walk through a TDD example with subscription validation

Let's make this concrete with an Itezan business rule: subscription start dates must be at least 3 days from today. A customer can't start a meal plan tomorrow because the kitchen needs lead time to prep.

Here's how a developer would build validateSubscriptionStartDate using TDD.

Phase 1: RED (write failing tests)

Before writing any implementation code, the developer writes tests that describe the expected behavior. These tests will fail immediately because the function doesn't exist yet.

import { describe, it, expect } from 'vitest'
import { validateSubscriptionStartDate } from './subscription-utils'

describe('validateSubscriptionStartDate', () => {
  // Test 1: Reject dates too close to today
  it('rejects a start date less than 3 days from today', () => {
    // Arrange: create a date that's only 1 day from now
    const tomorrow = new Date()
    tomorrow.setDate(tomorrow.getDate() + 1)

    // Act & Assert: calling with tomorrow should throw
    expect(() => validateSubscriptionStartDate(tomorrow))
      .toThrow('Start date must be at least 3 days from today')
  })

  // Test 2: Accept dates far enough in the future
  it('accepts a start date 3 or more days from today', () => {
    // Arrange: create a date that's 5 days from now
    const fiveDaysOut = new Date()
    fiveDaysOut.setDate(fiveDaysOut.getDate() + 5)

    // Act
    const result = validateSubscriptionStartDate(fiveDaysOut)

    // Assert: should return the date without throwing
    expect(result).toEqual(fiveDaysOut)
  })
})

What's happening in this RED phase?

Both tests fail immediately because validateSubscriptionStartDate doesn't exist yet. That's the point. The error would be something like "Cannot find module" or "is not a function."

Notice how the tests read like English: "It rejects a start date less than 3 days from today." The developer has defined the business rule as a testable contract before writing any logic.

Phase 2: GREEN (write minimum code to pass)

Now the developer writes the simplest possible implementation that makes both tests pass. Nothing more.

// The simplest implementation that makes the tests pass
export function validateSubscriptionStartDate(startDate: Date): Date {
  const today = new Date()
  const diffInMs = startDate.getTime() - today.getTime()
  const diffInDays = diffInMs / (1000 * 60 * 60 * 24)

  if (diffInDays < 3) {
    throw new Error('Start date must be at least 3 days from today')
  }

  return startDate
}

Why keep it minimal?

The GREEN phase isn't about writing perfect code. It's about turning those red test results green as quickly as possible. The function calculates the day difference and throws if it's under 3. That's all the tests require right now.

In the kitchen analogy: the chef's first attempt doesn't need to be a plated masterpiece. It just needs to pass the taste test.

Phase 3: REFACTOR (improve while tests stay green)

With passing tests as a safety net, the developer now asks: what edge cases did I miss? What about the exact boundary (exactly 3 days)? What about timezone issues?

First, they add new tests for the edge cases (back to RED for each new behavior):

// New edge case tests added during REFACTOR
it('accepts a start date that is exactly 3 days from today', () => {
  // Arrange: create a date exactly 3 days out at midnight
  const threeDaysOut = new Date()
  threeDaysOut.setDate(threeDaysOut.getDate() + 3)
  threeDaysOut.setHours(0, 0, 0, 0)

  // Act & Assert: exactly 3 days should be accepted
  const result = validateSubscriptionStartDate(threeDaysOut)
  expect(result).toEqual(threeDaysOut)
})

it('rejects dates in the past', () => {
  // Arrange: yesterday
  const yesterday = new Date()
  yesterday.setDate(yesterday.getDate() - 1)

  // Act & Assert
  expect(() => validateSubscriptionStartDate(yesterday))
    .toThrow('Start date must be at least 3 days from today')
})

How does TDD handle edge cases?

Each edge case starts a mini red/green cycle of its own. The "exactly 3 days" test forces the developer to decide: is 3 days inclusive or exclusive? The "past date" test catches something obvious that the original implementation already handles (negative days are less than 3), but now it's explicitly documented as expected behavior.

Timezone issues are trickier. If the server is in UTC and the customer is in UTC+12, "3 days from now" could mean different things. TDD surfaces this ambiguity early because writing the test forces you to define exactly what "3 days" means.

Then the developer might refactor the implementation to normalize dates to midnight, making the comparison timezone-safe. The original tests keep passing throughout, proving the refactoring didn't break anything.

Know when TDD helps and when it's overkill

TDD isn't a universal rule. It's a tool that works brilliantly in some situations and creates unnecessary ceremony in others.

The deciding question is: can you describe the behavior in English before coding it?

  • TDD shines for business logic with clear rules. Subscription validation, pricing calculations, permission checks, date arithmetic, discount logic. These all have definable inputs, outputs, and edge cases. You can say "given X, expect Y" before writing any code.
  • TDD is overkill for exploratory work. UI layout tweaks, one-off data migration scripts, rapid prototypes where you're still figuring out what the feature should do. If you don't yet know the correct behavior, you can't write a meaningful test for it.

The Itezan rule of thumb

In your F&B platform, TDD makes sense for anything touching money, dates, or permissions. "Can this restaurant's subscription be paused mid-cycle?" has a clear yes/no answer you can test. "Should the dashboard card have rounded corners?" does not.

When talking to your engineering team, a useful framing is: "Is this a rule or a preference?" Rules get TDD. Preferences get tested after implementation (if at all).

What about the code-first, test-after approach?

Writing tests after the code is still valuable. Most of the tests in your Itezan codebase were likely written this way. The advantage of TDD is that it forces you to think about requirements before implementation, catching design problems early.

Both approaches produce tested code. TDD produces code that was designed to be testable from the start, which often means simpler, more focused functions.

You now have the full TDD mental model. In the next step, you'll bring everything together: when tests run in your workflow, what makes a good test vs a bad test, and how to read a real test file from start to finish.

When Tests Run and How to Read a Real Test File

You now understand what tests are, how they're structured, and what tools run them. The final piece of the puzzle is understanding when tests actually execute in a real development workflow.

In this step, you'll learn about the four gates where tests catch bugs, what separates a good test from a bad one, and how to read a real test file from your Itezan codebase like a story.

In this step, get ready to:
  • Understand the four gates where tests run in a development workflow.
  • Identify what makes a test good or bad, and spot common test smells.
  • Read a real Itezan integration test file and understand every part of its structure.
When and where tests run

Tests don't just run when someone remembers to run them. In a professional workflow, there are four gates where tests execute automatically, each catching a different category of problem.

Think of it like a restaurant with multiple inspection checkpoints. The chef tastes while cooking, the expeditor checks before plating, the food runner checks before delivering, and the manager does a final table check.

  • In Cursor, open a new terminal.
  • Imagine running this command (don't run it yet, just read):
npm run test

What are the four gates?

Gate 1: Manual during development. Running npm run test starts Vitest in watch mode. Every time you save a file, related tests re-run instantly. This catches bugs as you work, like a chef tasting while cooking.

Gate 2: Pre-commit hooks. Husky runs tests before you can commit code. If a test fails, the commit is blocked. This catches what you forgot to check manually.

Gate 3: CI/CD. GitHub Actions runs all tests on every push and pull request. If tests fail, the PR cannot be merged. This catches code that works on your machine but breaks elsewhere.

Gate 4: Pre-deploy. A final test run before code reaches production. This is the last safety net, ensuring nothing slipped through the earlier gates.

Why four gates instead of one?

Each gate catches different types of problems. Manual catches bugs during active work. Pre-commit catches things you forgot to test. CI catches environment-specific issues ("works on my machine" problems). Pre-deploy is the final safety net before customers are affected.

The earlier a bug is caught, the cheaper it is to fix. A bug caught in watch mode takes seconds to fix. A bug caught in production takes hours and costs customer trust.

Good tests vs bad tests

Not all tests are equally valuable. Some tests actively hurt your team by being fragile, slow, or misleading. Knowing the difference helps you evaluate whether your team's testing strategy is strong.

The restaurant analogy works perfectly here. A good quality check tests "does the dish taste right?" A bad one tests "did the chef stir exactly 47 times?"

  • Read through these five qualities of a good test:
  • Clear name that explains what behavior it checks.
  • Tests one thing. Each test verifies a single behavior, not multiple unrelated things.
  • Runs fast. A slow test suite means developers stop running tests.
  • Deterministic. Gives the same result every time, regardless of time of day, machine, or order of execution.
  • Isolated. Doesn't depend on other tests' state. Can run in any order and still pass.

What are common test smells?

Flaky tests pass sometimes and fail sometimes. This usually happens because of timing or async issues. A flaky test is worse than no test because it trains your team to ignore failures.

Testing implementation details means checking HOW something works instead of WHAT it produces. Testing that a function was called exactly 3 times is fragile. Testing that the order was created correctly is durable.

Cleaning up data you didn't create is another smell. If your test deletes records using a broad pattern like WHERE name LIKE '%test%', it might destroy data another test created. Each test should only clean up its own specific records.

When you see a test in the Itezan codebase, mentally run through this checklist. Does it have a clear name? Does it test one behavior? Could it produce different results on different runs?

Read a real Itezan test file like a story

Everything you've learned in this project comes together here. You're going to read a real integration test file from your codebase and understand every part of it.

  • Switch back to tests/day-order-lifecycle.integration.test.ts in Cursor.
  • Read through the file from top to bottom, section by section.

Here's the anatomy you should see, section by section:

  • Imports at the top. These pull in test utilities, GraphQL queries, and helper functions. They tell you what external tools this test relies on.
  • A describe block wrapping all related tests. This groups the entire lifecycle (create, read, update, delete) under one label.
  • beforeAll and afterAll for container and data setup. These start Docker containers and seed initial data before any test runs, then tear everything down at the end.
  • Individual it blocks testing each lifecycle step. One for create, one for read, one for update, one for delete. Each tests a single behavior.
  • Cleanup in afterEach or afterAll. Deletes specific records by exact UUID to avoid contaminating other tests.

How to read each test as a story

Every it block tells a story using the Given/When/Then pattern. "Given a subscription exists, when I create a day order for Tuesday, then the day order appears in the database with the correct fields."

The "Given" is the Arrange phase (data setup). The "When" is the Act phase (the GraphQL mutation or query). The "Then" is the Assert phase (the expect calls checking the result).

  • Pick any single it block in the file and identify its three phases:
  • Arrange (Given): What data does it set up? Look for variable assignments and helper function calls at the top of the it block.
  • Act (When): What action does it perform? Look for a GraphQL mutation or query being executed.
  • Assert (Then): What does it check? Look for expect statements that verify the result matches expectations.

The naming tells the story

Look at the string inside each it() call. It reads like a sentence describing expected behavior. If you concatenate the describe label with the it label, you get a full English sentence. This is intentional and is a sign of well-written tests.

Now look at the cleanup logic. You should see records being deleted by their exact UUID. This is the surgical cleanup pattern you learned about in the test database isolation step.

Why exact UUIDs instead of broad patterns?

If a test used DELETE WHERE name LIKE '%test%', it could accidentally destroy data that another test created. Deleting by the exact UUID that was just inserted is safe and predictable. Each test only cleans up after itself.

  • Apply the "good test" checklist to the file you're reading:
  • Does each it block have a clear name explaining the behavior it checks?
  • Does each test verify one thing?
  • Is each test isolated from the others? Could they run in any order?
  • Is the cleanup surgical (by UUID) rather than broad?

Secret mission

Sketch a Test Plan for a New Feature

You've learned how to read and evaluate tests. Now it's time to think like the person who decides what gets tested. Design a test plan for a hypothetical new Itezan feature using everything you've learned about the test pyramid, TDD, mocking, and test isolation.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with no ongoing costs. You didn't install any new tools, provision any cloud resources, or create any new code files.

Decide whether to keep your test plan notes for future reference or discard them.

Resources you used:

  • Test plan document (your Pause/Resume Subscription test plan, written on paper or in a notes app).
  • Cursor workspace with Itezan codebase files open.

Keep everything running

No action needed. Choose this if you want to keep your test plan handy as a reference when discussing testing strategy with your engineering team.

  • Your test plan document stays wherever you saved it.
  • Cursor stays open with the Itezan codebase loaded, ready for future exploration.

Pause - I'll come back to this later

Free up screen space while keeping your work accessible for later.

  • Close any test files you opened in Cursor (the codebase itself stays intact).
  • Save your test plan document if you haven't already.

Delete - I don't want to use this again

Remove all project artefacts and start fresh.

  • Delete your test plan document if you no longer need it.
  • Close the Itezan workspace in Cursor.

Your codebase is untouched

This was a reading and learning project. You didn't modify any files in the Itezan repo, so there's nothing to revert or undo in your codebase.

That's a Wrap!

That's a Wrap!

Nice work! You now have a complete mental model for software testing, grounded in your own product and your own codebase.

You've learned how to:

  • Open any .test.ts or .test.tsx file in the Itezan repo and understand its structure, purpose, and quality.
  • Evaluate your team's testing strategy using the test pyramid, mocking decisions, database isolation patterns, and the four gates where tests run.
  • Make informed decisions about when TDD makes sense vs when it's overkill, and communicate those decisions with your engineering team.
  • Sketched a test plan for a real feature using TDD principles, complete with unit, integration, and E2E test names, mock/real decisions, and edge cases.

Ready to quiz yourself?