Transaction Monitor Dashboard with TDD

Build a transaction dashboard using TDD, Zod validation, and MSW mocking.

Introduction

30 Second Summary

Every time you check your bank balance or get a payment notification, a system somewhere is tracking, validating, and displaying that transaction in real time. Companies that handle money need dashboards they can trust completely.

In this project, you will build a Transaction Monitor Dashboard using Test-Driven Development, runtime data validation, and a feature-first architecture that proves to hiring managers you write production-quality frontend code.

What You'll Build

A dashboard that fetches, validates, and filters financial transactions using Zod schemas for runtime safety, MSW for network-level API mocking, and a full test suite written before the implementation code.

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

  • A production-grade Transaction Monitor Dashboard deployed to a live Vercel URL with full test coverage.
  • A feature-first folder architecture that demonstrates senior-level code organization to interviewers.
  • A GitHub repository with passing tests, clean commit history, and a professional README suitable for client review.
  • Secret Mission: Add an error boundary with retry logic and test coverage for failure states.

Are there any prerequisites?

You should have basic familiarity with Next.js and TypeScript. You also need a GitHub account and Node.js installed on your machine.

Set Up the Testing Infrastructure

Before you can write tests, you need a foundation that makes testing fast and realistic. This step scaffolds your Next.js project and wires up the TDD toolchain: Vitest for running tests, React Testing Library for rendering components, MSW for intercepting network requests, and Zod for runtime validation.

You will also create a feature-first folder structure that groups related files by feature (not by file type). This is the architecture pattern used by senior engineering teams because it keeps everything about a feature in one place.

In this step, get ready to:
  • Create a Next.js project and install the testing toolchain.
  • Configure Vitest with the correct plugins and test environment.
  • Set up the feature-first folder structure and verify everything works with a smoke test.
Create the Next.js project and install dependencies

Your testing tools need a project to live inside. You will scaffold a Next.js project with TypeScript, Tailwind, App Router, and ESLint, then layer on the testing packages.

  • Open your terminal.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Next.js project by running this command:
npx create-next-app@latest transaction-monitor --typescript --tailwind --app --eslint --src-dir --use-npm

What will the CLI ask me?

The flags in the command answer most of the setup questions automatically. You may still be asked a few remaining questions.

  • If asked about React Compiler, select No.
  • If asked about customizing the import alias, select No to keep the default @/*.
  • If asked about AGENTS.md, select No.
  • Move into the new project folder by running:
cd transaction-monitor

Now install the testing tools. Vitest is your test runner, and it integrates with Vite's plugin system for fast performance.

  • Install Vitest and its companion packages by running:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths

What are these packages?

  • vitest is the test runner (fast, Vite-native, TypeScript out of the box).
  • @vitejs/plugin-react lets Vitest understand JSX and React components.
  • jsdom simulates a browser DOM so component tests can run in Node.js.
  • @testing-library/react and @testing-library/dom provide utilities to render and query components the way a user would.
  • vite-tsconfig-paths lets Vitest resolve @/ import aliases from your tsconfig.json.
  • Install MSW (Mock Service Worker) as a dev dependency by running:
npm install -D msw
  • Install Zod as a production dependency by running:
npm install zod

Why is Zod a production dependency?

Zod validates API responses at runtime in your deployed app. Unlike the test tools (which only run on your machine), Zod runs in production to catch corrupted data before it reaches the UI.

  • Confirm the installs completed without errors by checking that no npm ERR! messages appeared in the terminal output.

Seeing install errors?

  • Make sure you are inside the transaction-monitor folder when running npm install commands. Run pwd to check your current directory.
  • Vitest requires Node.js v20.0.0 or higher. Run node --version and confirm it is 20.x or above.

help me fix npm install errors in my transaction-monitor project

Configure Vitest for Next.js

Vitest needs a config file to know which plugins to load and how to simulate the browser environment. You will also create a setup file that starts the MSW mock server before tests run.

  • Open the transaction-monitor project in VS Code.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the transaction-monitor folder, then click Open.
  • Create a new file called vitest.config.mts in the project root (the top level of transaction-monitor) by pasting the following code:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
})

What does this config do?

  • tsconfigPaths() resolves @/ imports in tests (matching your Next.js path aliases).
  • react() enables JSX transformation so tests can render React components.
  • environment: 'jsdom' simulates a browser so DOM queries work in Node.js.
  • setupFiles points to a file that runs before every test suite (you will create this next).
  • Create a new file called vitest.setup.ts in the project root with the following code:
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './src/mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

What does the setup file do?

This file controls the MSW mock server lifecycle across all your tests.

  • beforeAll starts the mock server once before the entire test suite.
  • afterEach resets any per-test handler overrides so tests stay isolated.
  • afterAll shuts down the mock server after all tests finish.
  • Open package.json and find the "scripts" section.
  • Add the following two scripts inside the "scripts" object:
"test": "vitest",
"test:run": "vitest run"

What is the difference between these two scripts?

  • npm run test starts Vitest in watch mode. It reruns tests automatically when you save a file.
  • npm run test:run runs all tests once and exits. This is useful for CI pipelines.
  • Save vitest.config.mts, vitest.setup.ts, and package.json.
Set up the feature-first folder structure and run a smoke test

A feature-first folder structure groups model, API, and UI code for each feature together. This keeps related files co-located instead of scattered across separate models/, api/, and components/ directories.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
  • Create the feature folder structure and mocks folder by running:
mkdir -p src/features/transactions/model src/features/transactions/api src/features/transactions/ui src/mocks
  • Confirm the folders were created by running:
ls src/features/transactions

You should see api, model, and ui listed.

Why feature-first over file-type folders?

When a project grows, having all transaction-related code in features/transactions/ means a developer can understand and modify the feature without jumping between six different top-level folders.

Each feature boundary (model, api, ui) maps to a testing layer: schema tests, integration tests, and component tests.

Now create the MSW placeholder files that vitest.setup.ts imports.

  • Create src/mocks/handlers.ts with the following code:
import { http, HttpResponse } from 'msw'

export const handlers = []
  • Create src/mocks/server.ts with the following code:
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

What do these files do?

  • handlers.ts will hold your mock API endpoint definitions. It starts empty because you have not written any handlers yet.
  • server.ts creates a mock server using setupServer from MSW. Your vitest.setup.ts file imports this server and manages its lifecycle automatically.

Finally, write a minimal smoke test to confirm the entire toolchain works together.

  • Create src/smoke.test.ts with the following code:
import { describe, it, expect } from 'vitest'

describe('smoke test', () => {
  it('confirms vitest is configured correctly', () => {
    expect(true).toBe(true)
  })
})
  • Save all files.
  • Run the test suite by running:
npm run test:run

You should see output indicating 1 test passed with a green checkmark.

Tests failing or not found?

  • If you see an error about ./src/mocks/server not being found, double check that src/mocks/server.ts and src/mocks/handlers.ts both exist and are saved.
  • If you see a config error, confirm vitest.config.mts is at the project root (same level as package.json), not inside src/.
  • If no tests are found, confirm src/smoke.test.ts has the .test.ts extension.

help me debug my failing vitest smoke test

✔️ Awesome, I've got everything!

Great. Double check you have saved all files, then move on to the next step.

ⓧ I'd like to double check the full code

Here are the four key files that should exist at the project root and in src/mocks/:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
})
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './src/mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
import { http, HttpResponse } from 'msw'

export const handlers = []
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

Your testing infrastructure is ready. Next up, you will use TDD to define your transaction data model with Zod schemas. You will write failing tests first, then make them pass.

TDD the Data Model with Zod Schemas

Your testing infrastructure is ready and a smoke test is passing. Now it's time to define what your transaction data actually looks like.

In Test-Driven Development, you write the tests first to define the expected behavior. The tests fail (RED), then you write just enough code to make them pass (GREEN), then you clean things up (REFACTOR). This step follows that exact cycle for your data model.

In this step, get ready to:
  • Write failing tests that define the expected shape of a transaction (RED).
  • Implement Zod schemas that make those tests pass (GREEN).
  • Export inferred TypeScript types and create a barrel file (REFACTOR).
Write tests that define the transaction data contract (RED phase)

Before writing any schema code, you define what valid data looks like through tests. This forces you to think about the data contract up front, not as an afterthought.

  • Create a new file at src/features/transactions/model/transaction.schema.test.ts and add the following test code:
import { describe, it, expect } from 'vitest'
import {
  TransactionSchema,
  TransactionsResponseSchema,
} from './transaction.schema'

describe('TransactionSchema', () => {
  it('validates a correct transaction object', () => {
    const valid = {
      id: 'txn_001',
      amount: 149.99,
      currency: 'USD',
      status: 'completed',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(valid)
    expect(result.success).toBe(true)
  })

  it('rejects a transaction with missing fields', () => {
    const invalid = { id: 'txn_001', amount: 149.99 }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })

  it('rejects an invalid status value', () => {
    const invalid = {
      id: 'txn_001',
      amount: 149.99,
      currency: 'USD',
      status: 'unknown',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })

  it('rejects a negative amount', () => {
    const invalid = {
      id: 'txn_001',
      amount: -50,
      currency: 'USD',
      status: 'completed',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })
})

describe('TransactionsResponseSchema', () => {
  it('validates a correct API response', () => {
    const valid = {
      transactions: [
        {
          id: 'txn_001',
          amount: 149.99,
          currency: 'USD',
          status: 'completed',
          merchant: 'Acme Corp',
          timestamp: '2026-06-20T10:30:00Z',
        },
      ],
      total: 1,
    }

    const result = TransactionsResponseSchema.safeParse(valid)
    expect(result.success).toBe(true)
  })
})

What does this test file do?

These tests define your data contract before the implementation exists. Each test uses safeParse to validate test objects against your (not-yet-created) schemas.

  • The first test asserts that a fully valid transaction passes validation.
  • The second test asserts that objects with missing required fields are rejected.
  • The third test asserts that only known status values (completed, pending, failed) are allowed.
  • The fourth test asserts that negative amounts are rejected.
  • The final describe block tests the full API response shape (an array of transactions plus a total count).
  • Save transaction.schema.test.ts.
  • Run the tests to confirm they fail (RED phase) by running:
npm run test:run

You should see all 5 tests fail because ./transaction.schema does not exist yet. This is exactly what RED means.

Seeing a different error?

If you see a file path error that does not mention transaction.schema, double check your test file is saved in the correct folder: src/features/transactions/model/.

Help me debug this issue

Implement the Zod schemas to make all tests pass (GREEN phase)

Now you write the minimum implementation that satisfies your tests. Zod schemas validate data at runtime, catching corrupted responses before they reach your UI.

  • Create a new file at src/features/transactions/model/transaction.schema.ts and add the following schema definitions:
import * as z from 'zod'

// Define the allowed transaction statuses
export const TransactionStatusSchema = z.enum([
  'completed',
  'pending',
  'failed',
])

// Define the shape of a single transaction
export const TransactionSchema = z.object({
  id: z.string(),
  amount: z.number().positive(),
  currency: z.string(),
  status: TransactionStatusSchema,
  merchant: z.string(),
  timestamp: z.string(),
})

// Define the shape of the full API response
export const TransactionsResponseSchema = z.object({
  transactions: z.array(TransactionSchema),
  total: z.number(),
})

What do these schemas do?

Each schema defines validation rules that run at runtime, not just at compile time. TypeScript types disappear when your code runs in the browser. Zod schemas stay active and catch invalid data before it causes bugs.

  • TransactionStatusSchema uses z.enum to restrict values to exactly three options.
  • TransactionSchema uses z.number().positive() on amount, which is why your negative-amount test will now correctly fail validation.
  • TransactionsResponseSchema wraps the single transaction schema in an array, defining the full API response shape.
  • Save transaction.schema.ts.
  • Run the tests again to confirm they now pass (GREEN phase):
npm run test:run

You should see all 5 tests pass with green checkmarks. That is the GREEN phase complete.

Tests still failing?

Make sure your schema file is named exactly transaction.schema.ts (not transaction-schema.ts or transactionSchema.ts). The test imports from './transaction.schema' so the filename must match.

Help me figure out what's wrong

Export TypeScript types and create the barrel file (REFACTOR phase)

Your schemas work, but right now other files would need to import types separately from schemas. The REFACTOR phase cleans this up by deriving TypeScript types directly from your schemas and creating a single entry point.

  • In src/features/transactions/model/transaction.schema.ts, find the closing line of TransactionsResponseSchema:
export const TransactionsResponseSchema = z.object({
  transactions: z.array(TransactionSchema),
  total: z.number(),
})
  • Add these type exports directly below it:
// Derive TypeScript types from the schemas (single source of truth)
export type Transaction = z.infer<typeof TransactionSchema>
export type TransactionStatus = z.infer<typeof TransactionStatusSchema>
export type TransactionsResponse = z.infer<typeof TransactionsResponseSchema>

Why derive types from schemas?

The z.infer utility extracts a TypeScript type from a Zod schema. This means your types and your runtime validation are always in sync.

If you wrote a separate interface Transaction { ... } by hand, it could drift out of sync with your schema over time. The z.infer pattern makes the schema the single source of truth.

  • Save transaction.schema.ts.
  • Create the barrel file at src/features/transactions/model/index.ts with this single line:
export * from './transaction.schema'
  • Save index.ts.
  • Run the tests one final time to confirm nothing broke:
npm run test:run

All 5 tests should still pass. Your REFACTOR phase is complete without changing any behavior.

Seeing import errors from the barrel file?

Make sure the path in index.ts is './transaction.schema' (relative, with no file extension). TypeScript resolves .ts files automatically.

Help me debug my barrel file

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in src/features/transactions/model/.

ⓧ I'd like to double check the full code

Here is the complete code for all three files in src/features/transactions/model/:

import { describe, it, expect } from 'vitest'
import {
  TransactionSchema,
  TransactionsResponseSchema,
} from './transaction.schema'

describe('TransactionSchema', () => {
  it('validates a correct transaction object', () => {
    const valid = {
      id: 'txn_001',
      amount: 149.99,
      currency: 'USD',
      status: 'completed',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(valid)
    expect(result.success).toBe(true)
  })

  it('rejects a transaction with missing fields', () => {
    const invalid = { id: 'txn_001', amount: 149.99 }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })

  it('rejects an invalid status value', () => {
    const invalid = {
      id: 'txn_001',
      amount: 149.99,
      currency: 'USD',
      status: 'unknown',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })

  it('rejects a negative amount', () => {
    const invalid = {
      id: 'txn_001',
      amount: -50,
      currency: 'USD',
      status: 'completed',
      merchant: 'Acme Corp',
      timestamp: '2026-06-20T10:30:00Z',
    }

    const result = TransactionSchema.safeParse(invalid)
    expect(result.success).toBe(false)
  })
})

describe('TransactionsResponseSchema', () => {
  it('validates a correct API response', () => {
    const valid = {
      transactions: [
        {
          id: 'txn_001',
          amount: 149.99,
          currency: 'USD',
          status: 'completed',
          merchant: 'Acme Corp',
          timestamp: '2026-06-20T10:30:00Z',
        },
      ],
      total: 1,
    }

    const result = TransactionsResponseSchema.safeParse(valid)
    expect(result.success).toBe(true)
  })
})
import * as z from 'zod'

export const TransactionStatusSchema = z.enum([
  'completed',
  'pending',
  'failed',
])

export const TransactionSchema = z.object({
  id: z.string(),
  amount: z.number().positive(),
  currency: z.string(),
  status: TransactionStatusSchema,
  merchant: z.string(),
  timestamp: z.string(),
})

export const TransactionsResponseSchema = z.object({
  transactions: z.array(TransactionSchema),
  total: z.number(),
})

export type Transaction = z.infer<typeof TransactionSchema>
export type TransactionStatus = z.infer<typeof TransactionStatusSchema>
export type TransactionsResponse = z.infer<typeof TransactionsResponseSchema>
export * from './transaction.schema'

Your data contract is locked down with tests and runtime validation. Next up, you will build the API layer that fetches data and validates it through these schemas using MSW to simulate network responses.

TDD the API Layer with MSW

Your Zod schemas now define the exact shape of valid transaction data. The next challenge is fetching that data from an API and validating it before it reaches the UI.

In this step, you will build the API fetch layer using MSW (Mock Service Worker) to simulate realistic network responses. MSW intercepts requests at the network level, which means your application code runs exactly as it would in production. No function mocking, no faking imports.

In this step, get ready to:
  • Write MSW request handlers that simulate a real transaction API.
  • Write integration tests for the fetch function, then implement it (Red-Green).
  • Test error scenarios using MSW runtime overrides.
Write MSW request handlers that simulate the transaction API

Before you can test your fetch function, you need a mock API that returns realistic transaction data. MSW handlers define what the fake server responds with when your code makes a network request.

  • In VS Code, open src/mocks/handlers.ts from the Explorer sidebar.
  • Replace the entire contents of the file with the following code:
import { http, HttpResponse } from 'msw'

// Realistic transaction data that conforms to our Zod schema
const mockTransactions = [
  {
    id: 'txn_001',
    amount: 149.99,
    currency: 'USD',
    status: 'completed',
    merchant: 'Acme Corp',
    timestamp: '2026-06-20T10:30:00Z',
  },
  {
    id: 'txn_002',
    amount: 89.5,
    currency: 'USD',
    status: 'pending',
    merchant: 'Globe Logistics',
    timestamp: '2026-06-21T14:15:00Z',
  },
  {
    id: 'txn_003',
    amount: 320.0,
    currency: 'USD',
    status: 'failed',
    merchant: 'Swift Payments',
    timestamp: '2026-06-22T09:45:00Z',
  },
]

// Intercept GET requests to the transactions endpoint
export const handlers = [
  http.get('https://api.example.com/transactions', () => {
    return HttpResponse.json({
      transactions: mockTransactions,
      total: mockTransactions.length,
    })
  }),
]

What does this code do?

The mockTransactions array holds three realistic transactions covering all three statuses: completed, pending, and failed. This data deliberately matches the shape your Zod schema expects.

The http.get() handler tells MSW to intercept any GET request to https://api.example.com/transactions and respond with the mock data wrapped in the expected { transactions, total } shape.

  • Save src/mocks/handlers.ts.

You will not see a change yet. The next chunk writes tests that exercise this handler.

Why MSW instead of mocking fetch directly?

When you mock fetch with vi.mock, your test replaces the real function with a fake. This skips serialization, headers, and the actual request/response cycle. Bugs hide in those gaps.

MSW intercepts at the network level. Your application code calls the real fetch, the request travels through the normal pipeline, and MSW catches it on the wire. If your code has a serialization bug or a missing header, the test will catch it.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/mocks/handlers.ts before moving on.

ⓧ I'd like to double check the full code

import { http, HttpResponse } from 'msw'

const mockTransactions = [
  {
    id: 'txn_001',
    amount: 149.99,
    currency: 'USD',
    status: 'completed',
    merchant: 'Acme Corp',
    timestamp: '2026-06-20T10:30:00Z',
  },
  {
    id: 'txn_002',
    amount: 89.5,
    currency: 'USD',
    status: 'pending',
    merchant: 'Globe Logistics',
    timestamp: '2026-06-21T14:15:00Z',
  },
  {
    id: 'txn_003',
    amount: 320.0,
    currency: 'USD',
    status: 'failed',
    merchant: 'Swift Payments',
    timestamp: '2026-06-22T09:45:00Z',
  },
]

export const handlers = [
  http.get('https://api.example.com/transactions', () => {
    return HttpResponse.json({
      transactions: mockTransactions,
      total: mockTransactions.length,
    })
  }),
]
Write integration tests for the fetch function (RED phase), then implement (GREEN phase)

With the mock API ready, you will now write a test that describes how your fetch function should behave. The test will fail at first because the function does not exist yet. This is the RED phase of TDD.

  • Create a new file at src/features/transactions/api/get-transactions.test.ts.
  • Paste the following test code that defines the happy-path behavior:
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../mocks/server'
import { getTransactions } from './get-transactions'

describe('getTransactions', () => {
  // Test 1: Happy path - API returns valid data
  it('returns validated transaction data on success', async () => {
    const result = await getTransactions()

    expect(result.success).toBe(true)
    if (result.success) {
      expect(result.data.transactions).toHaveLength(3)
      expect(result.data.transactions[0].merchant).toBe('Acme Corp')
    }
  })
})

What does this test do?

This test calls getTransactions() (which does not exist yet) and asserts that the result comes back with success: true, 3 transactions, and the first merchant matching 'Acme Corp'.

The imports for http, HttpResponse, and server are ready for the error tests you will add in the next substep.

  • Save the file and run your tests to confirm you are in the RED phase:
npm run test:run

You should see a failure. The test cannot import getTransactions because the file does not exist yet. This is expected. RED means your test is correctly detecting missing behavior.

Not seeing the expected failure?

If the test passes or shows an unrelated error, check that the import path './get-transactions' matches the file location you will create next. The file should live at src/features/transactions/api/.

Help me debug my failing test setup.

Now it is time to go GREEN. You will implement the fetch function so the test passes.

  • Create a new file at src/features/transactions/api/get-transactions.ts and paste the following implementation:
import {
  TransactionsResponseSchema,
  type TransactionsResponse,
} from '../model/transaction.schema'

// Discriminated union: either success with data, or failure with error message
export type FetchTransactionsResult =
  | { success: true; data: TransactionsResponse }
  | { success: false; error: string }

export async function getTransactions(): Promise<FetchTransactionsResult> {
  try {
    const response = await fetch('https://api.example.com/transactions')

    // Check for HTTP error status codes
    if (!response.ok) {
      return { success: false, error: `Server error: ${response.status}` }
    }

    const json = await response.json()

    // Validate the response data against our Zod schema
    const parsed = TransactionsResponseSchema.safeParse(json)

    if (!parsed.success) {
      return { success: false, error: 'Invalid data received from server' }
    }

    return { success: true, data: parsed.data }
  } catch {
    return { success: false, error: 'Network error: unable to reach server' }
  }
}

What does this code do?

  • FetchTransactionsResult is a discriminated union. Callers check result.success and TypeScript narrows the type automatically.
  • response.ok catches HTTP errors (500s, 404s) before parsing the body.
  • TransactionsResponseSchema.safeParse(json) validates that the response matches your Zod schema at runtime. This catches corrupted data before it reaches the UI.
  • The outer try/catch handles network-level failures (DNS errors, connection timeouts).
  • Save src/features/transactions/api/get-transactions.ts.
  • Run your tests again to confirm you are now GREEN:
npm run test:run

You should see your new test passing alongside the schema tests from the previous step. GREEN means your implementation satisfies the test.

Test still failing?

Check that the import path in get-transactions.ts reads '../model/transaction.schema'. If the folder structure does not match, the import will fail silently.

Also verify that your vitest.setup.ts still has the MSW server lifecycle hooks (beforeAll, afterEach, afterAll). Without these, MSW never starts listening.

Help me debug why my getTransactions test is failing.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

import {
  TransactionsResponseSchema,
  type TransactionsResponse,
} from '../model/transaction.schema'

export type FetchTransactionsResult =
  | { success: true; data: TransactionsResponse }
  | { success: false; error: string }

export async function getTransactions(): Promise<FetchTransactionsResult> {
  try {
    const response = await fetch('https://api.example.com/transactions')

    if (!response.ok) {
      return { success: false, error: `Server error: ${response.status}` }
    }

    const json = await response.json()
    const parsed = TransactionsResponseSchema.safeParse(json)

    if (!parsed.success) {
      return { success: false, error: 'Invalid data received from server' }
    }

    return { success: true, data: parsed.data }
  } catch {
    return { success: false, error: 'Network error: unable to reach server' }
  }
}
Test error scenarios using MSW runtime overrides

The happy path works. A senior engineer does not stop here. You need to prove that your function handles server errors, corrupted data, and network failures gracefully.

MSW provides a server.use() method that lets you override handlers for a single test. After the test completes, server.resetHandlers() in your setup file restores the defaults automatically.

  • In src/features/transactions/api/get-transactions.test.ts, find the closing }) of the describe block.
  • Add these three error scenario tests just before the closing }):
  it('returns an error when the server responds with 500', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Server error: 500')
    }
  })

  it('returns an error when the response has invalid data', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return HttpResponse.json({ invalid: 'data' })
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Invalid data received from server')
    }
  })

  it('returns an error on network failure', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return HttpResponse.error()
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Network error: unable to reach server')
    }
  })

What does server.use() do?

  • server.use() temporarily replaces the default handler for that endpoint. Only this one test sees the overridden response.
  • new HttpResponse(null, { status: 500 }) simulates a server crash. Your implementation catches this via response.ok.
  • HttpResponse.json({ invalid: 'data' }) simulates a corrupted response. Your Zod safeParse catches this before the bad data reaches the UI.
  • HttpResponse.error() simulates a network-level failure (DNS timeout, connection refused). Your try/catch handles this.
  • Save get-transactions.test.ts and run your tests to confirm all four pass:
npm run test:run

You should see all four API tests passing alongside your schema tests. Every error path is now proven by a test.

Seeing a failure on the network error test?

If the 'network failure' test fails, make sure your implementation uses a bare catch (not catch (error)) in the try/catch block. TypeScript strict mode requires you to handle or discard the error parameter.

Help me debug why my error scenario tests are failing.

  • Create a barrel file to export your API function. Create src/features/transactions/api/index.ts with the following content:
export * from './get-transactions'
  • Save src/features/transactions/api/index.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the api/ folder before moving on.

ⓧ I'd like to double check the full code

import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../mocks/server'
import { getTransactions } from './get-transactions'

describe('getTransactions', () => {
  it('returns validated transaction data on success', async () => {
    const result = await getTransactions()

    expect(result.success).toBe(true)
    if (result.success) {
      expect(result.data.transactions).toHaveLength(3)
      expect(result.data.transactions[0].merchant).toBe('Acme Corp')
    }
  })

  it('returns an error when the server responds with 500', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return new HttpResponse(null, { status: 500 })
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Server error: 500')
    }
  })

  it('returns an error when the response has invalid data', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return HttpResponse.json({ invalid: 'data' })
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Invalid data received from server')
    }
  })

  it('returns an error on network failure', async () => {
    server.use(
      http.get('https://api.example.com/transactions', () => {
        return HttpResponse.error()
      })
    )

    const result = await getTransactions()

    expect(result.success).toBe(false)
    if (!result.success) {
      expect(result.error).toBe('Network error: unable to reach server')
    }
  })
})

Your API layer is fully tested and validates every response at runtime. No corrupted data can sneak past your Zod boundary. Next up, you will TDD the React components that display this transaction data to users.

TDD the Transaction List UI

Your data model is validated and your API layer fetches and verifies transactions at runtime. The foundation is solid.

Now it is time to build the UI that actually shows these transactions to users. Following the same TDD rhythm, you will write failing component tests first, then implement the React components to make them pass.

In this step, get ready to:
  • Write component tests that define loading, data, empty, and filter states (RED).
  • Implement the TransactionList component to make all tests pass (GREEN).
  • Integrate the component into a Next.js page with Tailwind styling.
Write component tests for the TransactionList (RED phase)

In TDD, tests act as a specification for your component. Before writing any rendering logic, you will define exactly what the component should do across multiple states: loading, displaying data, empty results, and filtering.

  • Install @testing-library/jest-dom to get DOM matchers like toBeInTheDocument() by running:
npm install -D @testing-library/jest-dom
  • Add the jest-dom matchers to your test setup by opening vitest.setup.ts and adding this import at the top of the file, above the existing MSW imports:
import '@testing-library/jest-dom/vitest'

What does this import do?

This single import extends Vitest's expect() with DOM-specific matchers like toBeInTheDocument() and toHaveTextContent().

Without it, Vitest only knows how to assert on plain JavaScript values, not DOM elements.

  • Create the test file src/features/transactions/ui/transaction-list.test.tsx with the following 7 tests:
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { TransactionList } from './transaction-list'

describe('TransactionList', () => {
  it('shows loading state initially', () => {
    render(<TransactionList />)
    expect(screen.getByRole('status')).toHaveTextContent(
      'Loading transactions...'
    )
  })

  it('renders transactions after loading', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('Acme Corp')).toBeInTheDocument()
    })

    expect(screen.getByText('Globe Logistics')).toBeInTheDocument()
    expect(screen.getByText('Swift Payments')).toBeInTheDocument()
  })

  it('displays formatted amounts', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('$149.99')).toBeInTheDocument()
    })
  })

  it('displays status badges', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('completed')).toBeInTheDocument()
      expect(screen.getByText('pending')).toBeInTheDocument()
      expect(screen.getByText('failed')).toBeInTheDocument()
    })
  })

  it('filters transactions by status', async () => {
    render(<TransactionList statusFilter="completed" />)

    await waitFor(() => {
      expect(screen.getByText('Acme Corp')).toBeInTheDocument()
    })

    expect(screen.queryByText('Globe Logistics')).not.toBeInTheDocument()
    expect(screen.queryByText('Swift Payments')).not.toBeInTheDocument()
  })

  it('filters transactions by search query', async () => {
    render(<TransactionList searchQuery="globe" />)

    await waitFor(() => {
      expect(screen.getByText('Globe Logistics')).toBeInTheDocument()
    })

    expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument()
  })

  it('shows empty state when no transactions match', async () => {
    render(<TransactionList searchQuery="nonexistent" />)

    await waitFor(() => {
      expect(
        screen.getByText('No transactions found.')
      ).toBeInTheDocument()
    })
  })
})

What do these tests define?

  • The first test checks that a loading indicator with role="status" appears immediately on render.
  • The next three tests verify that transactions display with merchant names, formatted currency amounts, and status badges after the async fetch completes.
  • The filter tests confirm that passing statusFilter or searchQuery props narrows the visible list.
  • The empty state test ensures a friendly message appears when no results match.
  • Run the tests to confirm they fail (RED phase) by running:
npm run test

You should see all 7 tests fail because transaction-list.tsx does not exist yet. This is expected.

Seeing an import error instead of test failures?

If you see an error about toBeInTheDocument not being a function, confirm you installed @testing-library/jest-dom and added import '@testing-library/jest-dom/vitest' to the top of vitest.setup.ts.

If you see a module resolution error for ./transaction-list, that is the correct RED state. The file does not exist yet. Help me debug my test setup for component tests.

Implement the TransactionList component (GREEN phase)

Now you will write the minimum code needed to make all 7 tests pass. The component needs to handle loading, data display, filtering, and an empty state.

  • Create src/features/transactions/ui/transaction-list.tsx with the following implementation:
'use client'

import { useEffect, useState } from 'react'
import { getTransactions } from '../api/get-transactions'
import type { Transaction } from '../model/transaction.schema'

// Format a number as currency using the browser's Intl API
function formatAmount(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount)
}

// Map transaction status to Tailwind color classes
function getStatusColor(status: string): string {
  switch (status) {
    case 'completed':
      return 'bg-green-100 text-green-800'
    case 'pending':
      return 'bg-yellow-100 text-yellow-800'
    case 'failed':
      return 'bg-red-100 text-red-800'
    default:
      return 'bg-gray-100 text-gray-800'
  }
}

export interface TransactionListProps {
  statusFilter?: string
  searchQuery?: string
}

export function TransactionList({
  statusFilter,
  searchQuery,
}: TransactionListProps) {
  const [transactions, setTransactions] = useState<Transaction[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    async function fetchData() {
      setLoading(true)
      const result = await getTransactions()

      if (result.success) {
        setTransactions(result.data.transactions)
        setError(null)
      } else {
        setError(result.error)
      }

      setLoading(false)
    }

    fetchData()
  }, [])

  if (loading) {
    return <p role="status">Loading transactions...</p>
  }

  if (error) {
    throw new Error(error)
  }

  // Apply filters based on props
  const filtered = transactions.filter((txn) => {
    const matchesStatus = statusFilter
      ? txn.status === statusFilter
      : true
    const matchesSearch = searchQuery
      ? txn.merchant.toLowerCase().includes(searchQuery.toLowerCase())
      : true
    return matchesStatus && matchesSearch
  })

  if (filtered.length === 0) {
    return <p>No transactions found.</p>
  }

  return (
    <ul role="list" aria-label="Transactions">
      {filtered.map((txn) => (
        <li
          key={txn.id}
          className="flex items-center justify-between p-4 border-b border-gray-200"
        >
          <div>
            <p className="font-medium text-gray-900">{txn.merchant}</p>
            <p className="text-sm text-gray-500">
              {new Date(txn.timestamp).toLocaleDateString()}
            </p>
          </div>
          <div className="flex items-center gap-3">
            <span className="font-semibold">
              {formatAmount(txn.amount, txn.currency)}
            </span>
            <span
              className={`px-2 py-1 rounded-full text-xs font-medium capitalize ${getStatusColor(txn.status)}`}
            >
              {txn.status}
            </span>
          </div>
        </li>
      ))}
    </ul>
  )
}

What does this component do?

  • On mount, it calls getTransactions() (your validated fetch function from Step 3) and shows a loading indicator while waiting.
  • If the API returns an error, the component throws so a parent error boundary can catch it.
  • The filtered array applies both statusFilter and searchQuery props to narrow the displayed transactions.
  • Each transaction renders with Intl.NumberFormat for proper currency formatting and a colored status badge.
  • Save transaction-list.tsx and run the tests again:
npm run test

All 7 component tests should now pass (GREEN). Your total test count across the project should show all schema, API, and component tests passing.

Tests still failing?

Check that your file is named transaction-list.tsx (not .ts) since it contains JSX.

Verify the 'use client' directive is the very first line of the file. Next.js requires this for components that use hooks like useState and useEffect. Help me fix my TransactionList component tests.

Integrate the component into a Next.js page

The component works in tests, but it needs a page to live in. You will create the main page that renders the TransactionList with a dashboard layout.

  • Replace the contents of src/app/page.tsx with the following dashboard page:
'use client'

import { useState } from 'react'
import { TransactionList } from '../features/transactions/ui/transaction-list'
import { TransactionFilter } from '../features/transactions/ui/transaction-filter'

export default function Home() {
  const [statusFilter, setStatusFilter] = useState('')
  const [searchQuery, setSearchQuery] = useState('')

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold text-gray-900 mb-2">
        Transaction Monitor
      </h1>
      <p className="text-gray-600 mb-6">
        Real-time transaction tracking with validated data and filtering.
      </p>
      <TransactionFilter
        statusFilter={statusFilter}
        searchQuery={searchQuery}
        onStatusChange={setStatusFilter}
        onSearchChange={setSearchQuery}
      />
      <div className="bg-white rounded-lg shadow">
        <TransactionList
          statusFilter={statusFilter}
          searchQuery={searchQuery}
        />
      </div>
    </main>
  )
}

Why does this import TransactionFilter?

This page sets up the full dashboard structure including the filter component you will build in the next step. The TransactionFilter import will show a TypeScript error until you create that file in Step 5.

Your tests still pass because they test TransactionList in isolation. They do not import page.tsx.

  • Save page.tsx.
  • Confirm all tests still pass by running:
npm run test:run

You should see all schema, API, and component tests passing with no regressions.

Seeing test failures after adding page.tsx?

The page file itself is not tested, so it should not affect your test results. If you see new failures, check that you did not accidentally modify transaction-list.tsx while editing page.tsx. Help me debug test regressions after adding my page component.

✔️ Awesome, I've got everything!

Great. Double check you have saved both transaction-list.test.tsx, transaction-list.tsx, and page.tsx.

ⓧ I'd like to double check the full code

Here is the full code for each file created in this step.

import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { TransactionList } from './transaction-list'

describe('TransactionList', () => {
  it('shows loading state initially', () => {
    render(<TransactionList />)
    expect(screen.getByRole('status')).toHaveTextContent(
      'Loading transactions...'
    )
  })

  it('renders transactions after loading', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('Acme Corp')).toBeInTheDocument()
    })

    expect(screen.getByText('Globe Logistics')).toBeInTheDocument()
    expect(screen.getByText('Swift Payments')).toBeInTheDocument()
  })

  it('displays formatted amounts', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('$149.99')).toBeInTheDocument()
    })
  })

  it('displays status badges', async () => {
    render(<TransactionList />)

    await waitFor(() => {
      expect(screen.getByText('completed')).toBeInTheDocument()
      expect(screen.getByText('pending')).toBeInTheDocument()
      expect(screen.getByText('failed')).toBeInTheDocument()
    })
  })

  it('filters transactions by status', async () => {
    render(<TransactionList statusFilter="completed" />)

    await waitFor(() => {
      expect(screen.getByText('Acme Corp')).toBeInTheDocument()
    })

    expect(screen.queryByText('Globe Logistics')).not.toBeInTheDocument()
    expect(screen.queryByText('Swift Payments')).not.toBeInTheDocument()
  })

  it('filters transactions by search query', async () => {
    render(<TransactionList searchQuery="globe" />)

    await waitFor(() => {
      expect(screen.getByText('Globe Logistics')).toBeInTheDocument()
    })

    expect(screen.queryByText('Acme Corp')).not.toBeInTheDocument()
  })

  it('shows empty state when no transactions match', async () => {
    render(<TransactionList searchQuery="nonexistent" />)

    await waitFor(() => {
      expect(
        screen.getByText('No transactions found.')
      ).toBeInTheDocument()
    })
  })
})
'use client'

import { useEffect, useState } from 'react'
import { getTransactions } from '../api/get-transactions'
import type { Transaction } from '../model/transaction.schema'

function formatAmount(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount)
}

function getStatusColor(status: string): string {
  switch (status) {
    case 'completed':
      return 'bg-green-100 text-green-800'
    case 'pending':
      return 'bg-yellow-100 text-yellow-800'
    case 'failed':
      return 'bg-red-100 text-red-800'
    default:
      return 'bg-gray-100 text-gray-800'
  }
}

export interface TransactionListProps {
  statusFilter?: string
  searchQuery?: string
}

export function TransactionList({
  statusFilter,
  searchQuery,
}: TransactionListProps) {
  const [transactions, setTransactions] = useState<Transaction[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    async function fetchData() {
      setLoading(true)
      const result = await getTransactions()

      if (result.success) {
        setTransactions(result.data.transactions)
        setError(null)
      } else {
        setError(result.error)
      }

      setLoading(false)
    }

    fetchData()
  }, [])

  if (loading) {
    return <p role="status">Loading transactions...</p>
  }

  if (error) {
    throw new Error(error)
  }

  const filtered = transactions.filter((txn) => {
    const matchesStatus = statusFilter
      ? txn.status === statusFilter
      : true
    const matchesSearch = searchQuery
      ? txn.merchant.toLowerCase().includes(searchQuery.toLowerCase())
      : true
    return matchesStatus && matchesSearch
  })

  if (filtered.length === 0) {
    return <p>No transactions found.</p>
  }

  return (
    <ul role="list" aria-label="Transactions">
      {filtered.map((txn) => (
        <li
          key={txn.id}
          className="flex items-center justify-between p-4 border-b border-gray-200"
        >
          <div>
            <p className="font-medium text-gray-900">{txn.merchant}</p>
            <p className="text-sm text-gray-500">
              {new Date(txn.timestamp).toLocaleDateString()}
            </p>
          </div>
          <div className="flex items-center gap-3">
            <span className="font-semibold">
              {formatAmount(txn.amount, txn.currency)}
            </span>
            <span
              className={`px-2 py-1 rounded-full text-xs font-medium capitalize ${getStatusColor(txn.status)}`}
            >
              {txn.status}
            </span>
          </div>
        </li>
      ))}
    </ul>
  )
}
'use client'

import { useState } from 'react'
import { TransactionList } from '../features/transactions/ui/transaction-list'
import { TransactionFilter } from '../features/transactions/ui/transaction-filter'

export default function Home() {
  const [statusFilter, setStatusFilter] = useState('')
  const [searchQuery, setSearchQuery] = useState('')

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold text-gray-900 mb-2">
        Transaction Monitor
      </h1>
      <p className="text-gray-600 mb-6">
        Real-time transaction tracking with validated data and filtering.
      </p>
      <TransactionFilter
        statusFilter={statusFilter}
        searchQuery={searchQuery}
        onStatusChange={setStatusFilter}
        onSearchChange={setSearchQuery}
      />
      <div className="bg-white rounded-lg shadow">
        <TransactionList
          statusFilter={statusFilter}
          searchQuery={searchQuery}
        />
      </div>
    </main>
  )
}

Your TransactionList component is fully tested and renders validated data from the API layer. Next up, you will build the filter component, wire everything together, and deploy your dashboard to a live URL.

TDD Filtering and Deploy

Your transaction list renders data, validates responses, and handles errors gracefully. The final piece of the dashboard is user control. Filters let users narrow down transactions by status or search by merchant name.

In this step, you will write tests for filter interactions, implement the filter component, wire everything together, and deploy the finished dashboard to a live URL on Vercel.

In this step, get ready to:
  • Write and pass filter component tests using TDD.
  • Verify the full integration of filters with the transaction list.
  • Deploy the app to Vercel and push to GitHub with a professional README.
Write tests for the filter functionality (RED then GREEN)

The filter component needs to handle two interactions: selecting a transaction status from a dropdown, and typing a merchant name into a search field. You will write tests that assert these interactions fire the correct callbacks, then implement the component to make them pass.

The filter tests use @testing-library/user-event to simulate realistic browser interactions. This package is not installed yet.

  • Install the user-event library as a dev dependency by running this command:
npm install -D @testing-library/user-event

Why user-event instead of fireEvent?

The fireEvent utility dispatches DOM events directly. The userEvent library simulates how a real user interacts with the browser, including focus, blur, keystrokes, and pointer events in sequence.

This catches bugs that raw event dispatching misses, like components that rely on focus order or intermediate state changes.

  • Create the test file src/features/transactions/ui/transaction-filter.test.tsx and add the following test code:
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransactionFilter } from './transaction-filter'

describe('TransactionFilter', () => {
  it('calls onStatusChange when a status is selected', async () => {
    const user = userEvent.setup()
    const onStatusChange = vi.fn()

    render(
      <TransactionFilter
        statusFilter=""
        searchQuery=""
        onStatusChange={onStatusChange}
        onSearchChange={vi.fn()}
      />
    )

    await user.selectOptions(
      screen.getByLabelText('Filter by status'),
      'completed'
    )

    expect(onStatusChange).toHaveBeenCalledWith('completed')
  })

  it('calls onSearchChange when typing in the search input', async () => {
    const user = userEvent.setup()
    const onSearchChange = vi.fn()

    render(
      <TransactionFilter
        statusFilter=""
        searchQuery=""
        onStatusChange={vi.fn()}
        onSearchChange={onSearchChange}
      />
    )

    await user.type(screen.getByLabelText('Search by merchant'), 'acme')

    expect(onSearchChange).toHaveBeenCalled()
  })
})

What does this test code do?

Each test renders the TransactionFilter component with mock callback functions created by vi.fn(). This lets you assert that the component calls the right handler when the user interacts with it.

The first test simulates selecting "completed" from the status dropdown and checks that onStatusChange was called with that value. The second test simulates typing "acme" into the search input and checks that onSearchChange fired.

  • Save transaction-filter.test.tsx.
  • Run the tests to confirm they fail (RED phase) by running:
npm run test:run

You should see failures because ./transaction-filter does not exist yet. This is the RED phase of TDD.

Seeing a different error?

If you see a module resolution error for @testing-library/user-event, double-check that the install command completed successfully. Run npm install -D @testing-library/user-event again.

Help me debug my test setup for the transaction filter

Now implement the component to make the tests pass. This is the GREEN phase.

  • Create src/features/transactions/ui/transaction-filter.tsx and add the following component code:
'use client'

import type { TransactionStatus } from '../model/transaction.schema'

export interface TransactionFilterProps {
  statusFilter: string
  searchQuery: string
  onStatusChange: (status: string) => void
  onSearchChange: (query: string) => void
}

export function TransactionFilter({
  statusFilter,
  searchQuery,
  onStatusChange,
  onSearchChange,
}: TransactionFilterProps) {
  // Define the available transaction statuses for the dropdown
  const statuses: Array<{ value: string; label: string }> = [
    { value: '', label: 'All Statuses' },
    { value: 'completed', label: 'Completed' },
    { value: 'pending', label: 'Pending' },
    { value: 'failed', label: 'Failed' },
  ]

  return (
    <div className="flex flex-col sm:flex-row gap-3 mb-6">
      <select
        aria-label="Filter by status"
        value={statusFilter}
        onChange={(e) => onStatusChange(e.target.value)}
        className="border border-gray-300 rounded-md px-3 py-2 text-sm"
      >
        {statuses.map((s) => (
          <option key={s.value} value={s.value}>
            {s.label}
          </option>
        ))}
      </select>
      <input
        type="search"
        aria-label="Search by merchant"
        placeholder="Search merchant..."
        value={searchQuery}
        onChange={(e) => onSearchChange(e.target.value)}
        className="border border-gray-300 rounded-md px-3 py-2 text-sm flex-1"
      />
    </div>
  )
}

What does this component do?

The TransactionFilter component is a controlled component. It receives its current state (statusFilter and searchQuery) as props and fires callbacks when the user changes them.

The aria-label attributes make the inputs accessible and also let your tests query elements by their label. This is the recommended testing approach from React Testing Library.

  • Save transaction-filter.tsx.
  • Run the tests again to confirm they pass (GREEN phase):
npm run test:run

All filter tests should now pass. You have completed one full Red-Green cycle for the filter component.

Tests still failing?

Check that the aria-label values in your component match exactly what the tests query. The test looks for Filter by status and Search by merchant. These are case-sensitive.

Help me figure out why my filter tests are failing

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete transaction-filter.test.tsx:

import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TransactionFilter } from './transaction-filter'

describe('TransactionFilter', () => {
  it('calls onStatusChange when a status is selected', async () => {
    const user = userEvent.setup()
    const onStatusChange = vi.fn()

    render(
      <TransactionFilter
        statusFilter=""
        searchQuery=""
        onStatusChange={onStatusChange}
        onSearchChange={vi.fn()}
      />
    )

    await user.selectOptions(
      screen.getByLabelText('Filter by status'),
      'completed'
    )

    expect(onStatusChange).toHaveBeenCalledWith('completed')
  })

  it('calls onSearchChange when typing in the search input', async () => {
    const user = userEvent.setup()
    const onSearchChange = vi.fn()

    render(
      <TransactionFilter
        statusFilter=""
        searchQuery=""
        onStatusChange={vi.fn()}
        onSearchChange={onSearchChange}
      />
    )

    await user.type(screen.getByLabelText('Search by merchant'), 'acme')

    expect(onSearchChange).toHaveBeenCalled()
  })
})

And the complete transaction-filter.tsx:

'use client'

import type { TransactionStatus } from '../model/transaction.schema'

export interface TransactionFilterProps {
  statusFilter: string
  searchQuery: string
  onStatusChange: (status: string) => void
  onSearchChange: (query: string) => void
}

export function TransactionFilter({
  statusFilter,
  searchQuery,
  onStatusChange,
  onSearchChange,
}: TransactionFilterProps) {
  const statuses: Array<{ value: string; label: string }> = [
    { value: '', label: 'All Statuses' },
    { value: 'completed', label: 'Completed' },
    { value: 'pending', label: 'Pending' },
    { value: 'failed', label: 'Failed' },
  ]

  return (
    <div className="flex flex-col sm:flex-row gap-3 mb-6">
      <select
        aria-label="Filter by status"
        value={statusFilter}
        onChange={(e) => onStatusChange(e.target.value)}
        className="border border-gray-300 rounded-md px-3 py-2 text-sm"
      >
        {statuses.map((s) => (
          <option key={s.value} value={s.value}>
            {s.label}
          </option>
        ))}
      </select>
      <input
        type="search"
        aria-label="Search by merchant"
        placeholder="Search merchant..."
        value={searchQuery}
        onChange={(e) => onSearchChange(e.target.value)}
        className="border border-gray-300 rounded-md px-3 py-2 text-sm flex-1"
      />
    </div>
  )
}
Integrate filters with the transaction list

The page component already imports TransactionFilter and connects it to the TransactionList via shared state. Now that the filter file exists, the integration is complete.

  • Open src/app/page.tsx and confirm it contains the TransactionFilter import and the useState hooks for statusFilter and searchQuery.
  • Run the full test suite to verify no regressions:
npm run test:run

All tests should pass: schema tests, API tests, component tests, and filter tests. Every layer of the application is covered.

  • Start the dev server to see the filter in action:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see the Transaction Monitor dashboard with a status dropdown and a search input above the transaction list. The dropdown shows "All Statuses" by default.

How the filter integration works

The page.tsx component holds filter state with useState hooks. When the user changes a filter, the state updates and re-renders both TransactionFilter (keeping inputs in sync) and TransactionList (which filters the displayed data based on the props it receives).

This pattern is called "lifting state up." The page owns the state, and child components receive it as props. No prop drilling or global state management needed for this scale.

Filters not working in the browser?

Make sure page.tsx passes statusFilter and searchQuery to both TransactionFilter and TransactionList. If the list does not respond to filter changes, the state is not being shared between the two components.

Help me debug my filter integration

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files.

ⓧ I'd like to double check the full code

Here is the complete page.tsx:

'use client'

import { useState } from 'react'
import { TransactionList } from '../features/transactions/ui/transaction-list'
import { TransactionFilter } from '../features/transactions/ui/transaction-filter'

export default function Home() {
  const [statusFilter, setStatusFilter] = useState('')
  const [searchQuery, setSearchQuery] = useState('')

  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold text-gray-900 mb-2">
        Transaction Monitor
      </h1>
      <p className="text-gray-600 mb-6">
        Real-time transaction tracking with validated data and filtering.
      </p>
      <TransactionFilter
        statusFilter={statusFilter}
        searchQuery={searchQuery}
        onStatusChange={setStatusFilter}
        onSearchChange={setSearchQuery}
      />
      <div className="bg-white rounded-lg shadow">
        <TransactionList
          statusFilter={statusFilter}
          searchQuery={searchQuery}
        />
      </div>
    </main>
  )
}
Deploy to Vercel and push to GitHub

Your dashboard is feature-complete with full test coverage. The final step is making it publicly accessible so hiring managers and collaborators can see it live.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Initialize a Git repository and create your first commit by running these commands:
git init
git add .
git commit -m "feat: transaction monitor with TDD, Zod validation, and MSW mocking"

What does this commit message mean?

The feat: prefix follows the Conventional Commits standard. It tells anyone reading the git log that this commit introduces a new feature. Clean commit messages are part of the professional signal hiring managers look for in portfolio repositories.

  • Create a new repository on GitHub.
  • Go to https://github.com/new in your browser.
  • Enter transaction-monitor as the repository name.
  • Leave it as **Public** and do not initialize with a README (you will add one locally).
  • Click **Create repository**.
  • Push your local repository to GitHub by running these commands (replace the URL with your own repository URL):
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/transaction-monitor.git"]]
git branch -M main
git push -u origin main
  • Connect the repository to Vercel for automatic deployment.
  • Go to https://vercel.com and sign in with your GitHub account (create a free account if you do not have one).
  • Click **Add New Project**.
  • Select your transaction-monitor repository from the list.
  • Leave all settings at their defaults and click **Deploy**.

Vercel will build and deploy your app. After a minute or two, you will see a success screen with your live URL.

What is Vercel?

Vercel is a hosting platform optimized for Next.js applications. It auto-deploys every time you push to GitHub. The free tier is perfect for portfolio projects.

  • Copy your live Vercel URL from the deployment success screen.
  • Add a professional README to your project. Create a README.md file at the project root with the following content:
# Transaction Monitor Dashboard

A production-grade transaction monitoring dashboard built with Test-Driven Development.

## Architecture

```
src/
  features/
    transactions/
      model/      # Zod schemas + TypeScript types
      api/        # Data fetching with runtime validation
      ui/         # React components (list, filter)
  mocks/          # MSW request handlers
  app/            # Next.js pages
```

## Tech Stack

- **Framework**: Next.js (App Router)
- **Language**: TypeScript
- **Testing**: Vitest + React Testing Library + MSW
- **Validation**: Zod (runtime schema validation)
- **Styling**: Tailwind CSS
- **Deployment**: Vercel

## Running Tests

```bash
npm run test       # Watch mode
npm run test:run   # Single run
```

## Live Demo

[View the live app](YOUR_VERCEL_URL_HERE)

Replace the demo URL

Swap YOUR_VERCEL_URL_HERE in the README with the actual URL from your Vercel deployment. This makes your repository ready for sharing with hiring managers.

  • Save README.md and push the update to GitHub:
git add .
git commit -m "docs: add professional README with architecture and demo link"
git push

Vercel will automatically redeploy when you push. Your live dashboard now has a professional README backing it up on GitHub.

Vercel build failing?

The most common cause is a TypeScript or lint error that does not surface in development mode. Run npm run build locally to see the exact error. Fix it, commit, and push again.

Help me debug my Vercel deployment failure

Your Transaction Monitor Dashboard is live, tested, and backed by a clean GitHub repository. Next up, you will take on the Secret Mission and prove you can handle failure states like a senior engineer.

Secret mission

Error Boundary with Retry Logic

Your dashboard handles the happy path beautifully. But what happens when the API goes down? Senior engineers are defined by how their code handles failure, not success. In this mission, you'll build a React Error Boundary with retry logic and full test coverage for failure states.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with a free Vercel deployment, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The transaction-monitor project folder on your Desktop.
  • A GitHub repository storing your project code.
  • A Vercel project hosting your live deployment.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your live Vercel URL available for portfolio reviews.

  • Your Vercel deployment stays live at its generated URL on the free tier.
  • Your GitHub repository remains accessible for interviewers or collaborators.
  • Your local project folder stays on your Desktop ready for further development.

Pause - I'll come back to this later

Stop any running processes to free up terminal sessions and memory. Your files and deployments stay intact.

  • Stop the Next.js dev server if it's still running by pressing Ctrl+C in the terminal.
  • Your Vercel deployment remains live on the free tier. No action needed there.
  • Your GitHub repository stays as-is. You can pick up where you left off by opening the transaction-monitor folder in VS Code again.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Delete the local project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/transaction-monitor

Windows

rmdir /s /q %USERPROFILE%\Desktop\transaction-monitor
  • Delete the GitHub repository by going to your repository page, clicking Settings, scrolling to the Danger Zone section, and clicking Delete this repository.
  • Delete the Vercel project by opening your Vercel dashboard, selecting the project, navigating to Settings > General, scrolling to the bottom, and clicking Delete Project.

Nice Work!

Nice Work!

You just built and deployed a production-grade Transaction Monitor Dashboard using Test-Driven Development. This is the exact workflow senior engineers use in fintech, ecommerce, and logistics teams every day.

You've learned how to:

  • Practice the TDD workflow (Red-Green-Refactor) for every feature layer: schemas, API fetching, UI components, and filters.
  • Implement runtime data validation with Zod at the API boundary, catching corrupted data before it reaches the UI.
  • Architect a feature-first folder structure with MSW network-level mocking, and deploy the full test-covered app to a live Vercel URL.
  • Secret Mission: Added error boundaries with retry logic and failure-state test coverage.

Ready to quiz yourself?