Learning path · Level 12 · Tutorial 1

Test TypeScript Logic with Vitest

Set up Vitest in a local TypeScript project and write independent tests for application rules and runtime data parsers, including edge cases and expected failures.

  • Level: Intermediate
  • Duration: 20 min
  • 8 steps
Start tutorial Back to the path
Test TypeScript Logic with Vitest

What you will go through

  1. Set Up a Local Logic-Test Project Prepare a local TypeScript project with Vitest, scripts for watch and single-run modes, and a separate static type-check command. 3 min
  2. Write and Run a First Named Test Write a first Vitest unit test for workshop-booking logic, then run it once and in watch mode on your local computer. 2 min
  3. Assert Structured Parser Results Assert a normalized runtime parser result with structural equality rather than object identity. 2 min
  4. Test Expected Failures Write passing Vitest tests for invalid booking data by letting `toThrow` observe the parser’s expected exception. 2 min
  5. Choose Cases from the Contract Build a compact contract-based test matrix for booking totals and runtime booking parsing. 2 min
  6. Parameterize Cases Without Sharing State Turn repeated booking-parser cases into readable parameterized tests while keeping every test independent of execution order. 3 min
  7. Diagnose a Failure and Preserve the Regression Test Trace an empty-input failure from a Vitest assertion to a faulty accumulator, repair the local implementation, and retain the reproducing regression test. 3 min
  8. Complete and Review the Logic-Test Suite Finish the local booking logic-test suite, run its two separate checks, and explain what their results do and do not demonstrate. 2 min

What you will learn

  • Install Vitest locally and run tests in watch mode and single-run mode.
  • Write named tests that assert primitive results, structured results, and expected exceptions.
  • Select ordinary, boundary, empty, and malformed-input cases for domain functions and runtime parsers.
  • Diagnose a failed assertion and retain a reproducing test after correcting the defect.

Before you start

  • Prepare a Local Project with Node.js and npm
  • Enable Strict Type Checking in a Local Vite Project
  • Organize React Features by Responsibility
  • Validate Unknown Data at Typed API Boundaries

Step 1 of 8

Set Up a Local Logic-Test Project

Prepare a local TypeScript project with Vitest, scripts for watch and single-run modes, and a separate static type-check command.

Test logic without a browser

What this project tests

A unit test checks a function’s observable contract: given an input, what result or failure should a caller observe? It does not need a browser, React rendering, or a live service.

In this tutorial, the contracts are workshop-booking rules in ordinary TypeScript modules. Vitest runs those local logic checks in Node’s default environment. Component and browser tests are separate concerns for later tutorials.

A TypeScript booking function feeds separately into a test runner and a type checker, with no browser involved.

Vitest checks runtime behavior; the TypeScript compiler performs a separate static check.

Create the project files

Create the local scaffold

Create a self-contained folder named vitest-booking-lab. In a terminal, install the runner, TypeScript, and Node type definitions as development dependencies. The install creates package.json and a lockfile; create the src folder for the TypeScript files.

Before installing, ensure your installed Node.js version satisfies the Vitest version’s Node requirement. Check your local version with node --version, then compare it with the requirement reported by the Vitest release you install.

Terminal setup

Run these commands from the parent folder where you want the project to live.

bash
mkdir vitest-booking-lab
cd vitest-booking-lab
npm init -y
npm install -D vitest typescript @types/node
mkdir src

tsconfig.json

Create tsconfig.json in the project root with this strict configuration. It includes only files in src.

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src"]
}

Tip

Name the colocated test file

Create the files src/booking.ts and src/booking.test.ts in your editor. Vitest recognizes booking.test.ts as a test file because of its name. You will add the first implementation and test in the next step.

Configure scripts and understand modes

Add local scripts

Set the project to use ES modules and add the three scripts below. These scripts are different checks, even though each is started with npm.

Do not run the test scripts yet: the first test is added in the next step.

Package configuration commands

Run these commands inside vitest-booking-lab. They update the package.json you created.

bash
npm pkg set type=module
npm pkg set scripts.test="vitest"
npm pkg set scripts.test:run="vitest run"
npm pkg set scripts.typecheck="tsc --noEmit"

Local verification commands

Use these commands after tests exist.

Each command has a distinct purpose.
CommandPurposeWhen it finishes
npm testStarts Vitest in watch modeAfter you stop it
npm run test:runRuns Vitest onceAfter one test run
npm run typecheckRuns static TypeScript checkingAfter one compiler check

Warning

Keep runtime tests and type checks separate

Watch mode stays open and reruns relevant tests after saved changes. Single-run mode executes once and exits, which is useful for a deliberate full check.

Vitest transforms and executes TypeScript test files. tsc --noEmit instead analyzes types without writing JavaScript output. A passing test run does not replace a successful type check, and a successful type check does not prove runtime behavior.

Check your setup understanding

Match commands to their purpose

Match each command to the check it performs.

Match every command with its purpose.

Tap an item and then its matching pair.

Complete the scripts

Complete the values for the three package scripts.

json
{
  "scripts": {
    "test": "",
    "test:run": "",
    "typecheck": ""
  }
}

Step 2 of 8

Write and Run a First Named Test

Write a first Vitest unit test for workshop-booking logic, then run it once and in watch mode on your local computer.

Define a small, testable contract

The function under test

A unit test checks a function’s observable contract without a browser, React rendering, or a live service. For this workshop example, totalSeats receives valid bookings and returns their combined seat count.

In src/booking.ts, define the shared type and function below. The reduction starts at 0, so an empty collection can later produce a numeric total without special setup. The function reads each booking and does not change the input array.

Two booking records with seat counts 2 and 3 flow into a total of 5 while the original records remain unchanged.

totalSeats reads booking records and returns one numeric total.

src/booking.ts

Create or replace src/booking.ts with this implementation.

typescript
export type Booking = {
  attendee: string;
  seats: number;
};

export function totalSeats(bookings: Booking[]): number {
  return bookings.reduce((total, booking) => total + booking.seats, 0);
}

Write the first named test

Name behavior, not implementation

Put the colocated test in src/booking.test.ts. The explicit Vitest imports provide the test API:

  • describe groups behavior for one exported function.
  • it gives one observable behavior a readable name.
  • expect checks the result.

The test follows Arrange, Act, Assert. It verifies the returned number with toBe; it does not care whether the implementation uses reduce, a loop, or another internal technique.

src/booking.test.ts

Create src/booking.test.ts.

typescript
import { describe, it, expect } from 'vitest';
import { totalSeats } from './booking';

describe('totalSeats', () => {
  it('returns total seats for standard bookings', () => {
    // Arrange
    const bookings = [
      { attendee: 'Ada', seats: 2 },
      { attendee: 'Lin', seats: 3 }
    ];

    // Act
    const result = totalSeats(bookings);

    // Assert
    expect(result).toBe(5);
  });
});

Tip

Keep the assertion focused

A good case name reads like a behavior statement. If this test fails, its name tells you what outcome the function did not provide—not which implementation detail changed.

Use both local run modes

Run once

First, run the suite once. This is single-run mode: Vitest executes the discovered tests and then finishes.

Single-run mode

Run this command from the vitest-booking-lab folder.

bash
npm run test:run
Expected output
> vitest-booking-lab@1.0.0 test:run
> vitest run

 RUN  v2.1.9 /path/to/vitest-booking-lab

 ✓ src/booking.test.ts (1 test) 3ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  10:24:00
   Duration  412ms

The Vitest version, timings, and exact formatting can differ. The important result is one passing test file and one passing test.

Watch mode

Then start watch mode. While it remains active, make a harmless change in src/booking.test.ts—for example, add one space to a blank line—and save. Vitest reruns the test after the save. Stop it with Ctrl+C in the terminal.

bash
npm test
Expected output
> vitest-booking-lab@1.0.0 test
> vitest

 DEV  v2.1.9 /path/to/vitest-booking-lab

 ✓ src/booking.test.ts (1 test) 3ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

After each saved test-file change, Vitest reports another run. The process stays active until you interrupt it.

Practice: arrange, act, and assert

Complete the named test

Complete the missing test-section labels, function call, and expected number.

typescript
import { describe, it, expect } from 'vitest';
import { totalSeats } from './booking';

describe('totalSeats', () => {
  it('returns total seats for standard bookings', () => {
    // 
    const bookings = [
      { attendee: 'Ada', seats: 2 },
      { attendee: 'Lin', seats: 3 }
    ];

    // 
    const result = (bookings);

    // 
    expect(result).toBe();
  });
});

Step 3 of 8

Assert Structured Parser Results

Assert a normalized runtime parser result with structural equality rather than object identity.

Parse unknown data into a Booking

The successful parser contract

parseBooking is a runtime boundary: it receives unknown data and returns a Booking only when the value meets the booking contract.

For a successful result, the input must be a non-null object, not an array. It needs an attendee string that remains nonempty after trimming, plus an integer seats value from 1 through 6. The parser does not convert '2' into the number 2.

A successful result is a new Booking. It contains the trimmed attendee and validated seat count, but not unrelated input fields.

From external record to Booking

A diagram shows a raw object with whitespace and an extra field becoming a separate Booking object with only normalized attendee and seats values.

The parser normalizes the attendee and creates a new object with only the Booking fields.

Supply the function under test

Add the parser

Keep the existing Booking type and totalSeats function in src/booking.ts. Add this complete parser export below them. Its implementation is supplied so this step can focus on checking its successful returned result.

src/booking.ts

typescript
export function parseBooking(value: unknown): Booking {
  if (
    typeof value !== 'object' ||
    value === null ||
    Array.isArray(value)
  ) {
    throw new Error('Invalid booking');
  }

  const record = value as Record<string, unknown>;
  const attendee = record.attendee;
  const seats = record.seats;

  if (
    typeof attendee !== 'string' ||
    attendee.trim() === '' ||
    typeof seats !== 'number' ||
    !Number.isInteger(seats) ||
    seats < 1 ||
    seats > 6
  ) {
    throw new Error('Invalid booking');
  }

  return { attendee: attendee.trim(), seats };
}

Tip

Result shape, not input identity

Notice the return statement: it constructs a fresh object instead of returning the input record. The parser's observable result is therefore its fields and values, not its reference identity.

Assert the returned structure

Choose the matcher for the value

For a number, toBe checks a primitive result. For a parser result, use toEqual to compare the Booking structure and field values.

Two object literals with matching fields are still separate objects in memory. toBe is identity-style comparison for objects, so it is not appropriate when the expected Booking is constructed separately.

src/booking.test.ts

Replace the contents of src/booking.test.ts with this cumulative file. It preserves the earlier totalSeats suite, imports both application functions once, and adds the parseBooking suite without duplicate Vitest imports.

typescript
import { describe, it, expect } from 'vitest';
import { totalSeats, parseBooking } from './booking';

describe('totalSeats', () => {
  it('returns total seats for standard bookings', () => {
    // Arrange
    const bookings = [
      { attendee: 'Ada', seats: 2 },
      { attendee: 'Lin', seats: 3 }
    ];

    // Act
    const result = totalSeats(bookings);

    // Assert
    expect(result).toBe(5);
  });
});

describe('parseBooking', () => {
  it('returns a trimmed booking from a valid record', () => {
    // Arrange
    const input: unknown = {
      attendee: '  Ada  ',
      seats: 2,
      note: 'window seat'
    };

    // Act
    const booking = parseBooking(input);

    // Assert
    expect(booking).toEqual({ attendee: 'Ada', seats: 2 });
  });
});

Primitive and structured comparisons

Select the matcher based on the expected value type.

The matcher should reflect the kind of result being checked.
Expected resultAppropriate assertionWhy
A numeric totalexpect(total).toBe(5)Numbers are primitive values.
A returned Bookingexpect(booking).toEqual(expected)Fields and values define the required structure.
A separate expected objectAvoid toBeMatching fields do not create the same object reference.

Apply structural equality

Complete the normalized-result assertion

Complete the assertion for this valid parser result.

typescript
const parsed = parseBooking({
  attendee: ' Lin ',
  seats: 3,
  source: 'signup'
});

expect(parsed).({ attendee: , seats:  });

Explain your matcher choice

In one or two sentences, explain why toBe is inappropriate when the parser result and expected Booking are separately constructed objects.

Write at least 40 characters (0/40).

Step 4 of 8

Test Expected Failures

Write passing Vitest tests for invalid booking data by letting toThrow observe the parser’s expected exception.

Make rejection part of the test contract

Expected failure is observable behavior

Invalid external data is part of a parser’s contract. When parseBooking receives invalid input, the correct behavior is to throw Error('Invalid booking')—not to return a partial booking.

A rejection test therefore passes when Vitest observes that expected exception. An exception that occurs outside the assertion, or no exception at all, makes the test fail.

toThrow('Invalid booking') checks whether the thrown error message contains that text. It does not require an exact-message equality match.

Let the matcher observe the exception

Comparison diagram showing a direct function call throwing before an assertion and a callback allowing the assertion to observe the throw.

Put the parser call inside a callback so the toThrow matcher can invoke it.

Test two invalid inputs

Add two rejection tests

Inside the existing describe('parseBooking', ...) suite in src/booking.test.ts, add these two test cases. Each callback delays the parser call until the matcher evaluates it.

typescript
it('rejects null', () => {
  // Arrange
  const input: unknown = null;

  // Act
  const act = () => parseBooking(input);

  // Assert
  expect(act).toThrow('Invalid booking');
});

it('rejects a string seat count', () => {
  // Arrange
  const input: unknown = { attendee: 'Ada', seats: '2' };

  // Act
  const act = () => parseBooking(input);

  // Assert
  expect(act).toThrow('Invalid booking');
});

Run the rejection tests

Save the file, then run the suite once. Your exact timing and reporter formatting can vary, but the two new rejection cases should be reported as passing.

bash
npm run test:run
Expected output
> vitest-booking-lab@1.0.0 test:run
> vitest run

 ✓ src/booking.test.ts (4 tests)

 Test Files  1 passed (1)
      Tests  4 passed (4)

The four tests are the two cases from earlier steps plus the two rejection cases here. Timing and formatting vary by Vitest version.

Distinguish expected from unexpected failure

Recognize a passing rejection test

Which outcome makes a parser-rejection test pass?

Practice: defer the parser call

Repair the exception assertion

Repair the exception assertion by supplying the callback expression that Vitest should invoke.

typescript
describe('parseBooking', () => {
  it('rejects invalid input', () => {
    // Arrange
    const input: unknown = null;

    // Act
    const act = ;

    // Assert
    expect(act).toThrow('Invalid booking');
  });
});

Step 5 of 8

Choose Cases from the Contract

Build a compact contract-based test matrix for booking totals and runtime booking parsing.

Derive cases from the contract

Start with observable categories

Choose cases from the written contract, not from the current implementation. A test matrix asks: which categories of input can produce meaningfully different observable outcomes?

For totalSeats, the contract distinguishes an ordinary collection, one booking, and no bookings. For parseBooking, the valid seat range has endpoints and nearby invalid values. External data can also have the wrong shape before it has become a Booking.

A focused matrix uses representative examples. It gives useful evidence for the contract; it does not prove every possible input is correct.

Map input categories to contracts

A diagram contrasts ordinary, single, and empty booking collections with a parser range from 1 through 6 and malformed external data shapes.

Different input categories deserve distinct test cases because their expected outcomes differ.

Record behavior, not implementation branches

A focused booking matrix

Use one compact matrix to record the category, the required outcome, and the matcher that observes it. The examples below are representative—not an invitation to force malformed values into a typed domain function.

Case categories for the two exported functions.
FunctionInput categoryExpected behaviorMatcher
totalSeatsOrdinary multi-booking listReturns 5toBe
totalSeatsSingle bookingReturns that booking's seat counttoBe
totalSeatsEmpty collectionReturns 0toBe
parseBookingValid record, seats 1Returns a BookingtoEqual
parseBookingValid record, seats 6Returns a BookingtoEqual
parseBookingSeats 0Throws Invalid bookingtoThrow
parseBookingSeats 7Throws Invalid bookingtoThrow
parseBookingNon-integer seatsThrows Invalid bookingtoThrow
parseBookingnull, array, or missing fieldThrows Invalid bookingtoThrow
parseBookingWrong field typeThrows Invalid bookingtoThrow
parseBookingWhitespace-only attendeeThrows Invalid bookingtoThrow

Tip

Keep domain data and unknown data separate

Keep the boundary explicit: totalSeats receives Booking[], so its cases should be valid typed bookings. Values such as null, arrays, missing fields, and wrong field types arrive as unknown at the parser boundary.

Do not use an unsafe cast just to feed malformed data to totalSeats. That would test a situation outside its typed contract and hide where validation belongs.

Practice: find missing contract cases

Complete the missing matrix rows

A partial matrix already contains these rows:

  • ordinary multi-booking total → primitive comparison
  • parser seats 0 → expected exception
  • parser seats 7 → expected exception
  • parser non-integer seats → expected exception

Identify the three missing contract rows needed to cover the empty total and both accepted parser endpoints. For each row, state the input category, expected behavior, and matcher.

Write at least 120 characters (0/120).

Step 6 of 8

Parameterize Cases Without Sharing State

Turn repeated booking-parser cases into readable parameterized tests while keeping every test independent of execution order.

Use rows for repeated behavior

Repeat one rule with object rows

it.each is useful when several examples exercise one behavioral rule and differ only in their data. Store each example in an object row, then use its fields in the test title and assertion.

Use descriptive label values so a failure report identifies the input category. Keep accepted and rejected parser rows in separate parameterized groups: accepted rows return a Booking to compare, while rejected rows must throw.

Rows are data, not automatic fresh setup. If a test changes an array or object from a row, create independent local values before changing them.

One row pattern, two clear outcomes

Object rows flow into separate successful-parser and rejected-parser test groups.

Separate parameterized groups when the observable outcome differs: returned data versus an expected exception.

Build compact parameterized groups

Start the cumulative test file

Replace the entire contents of src/booking.test.ts with this first segment. It keeps the ordinary total cases and gives the empty-collection behavior its own named test. The type-only import and Booking[] annotation ensure that the empty fixture remains valid under the project's strict type check. Then append the next two segments on this screen, in order.

src/booking.test.ts — segment 1

Replace the file contents with this first segment.

typescript
import { describe, it, expect } from 'vitest';
import { totalSeats, parseBooking } from './booking';
import type { Booking } from './booking';

describe('totalSeats', () => {
  it('returns total seats for standard bookings', () => {
    // Arrange
    const bookings = [
      { attendee: 'Ada', seats: 2 },
      { attendee: 'Lin', seats: 3 }
    ];

    // Act
    const result = totalSeats(bookings);

    // Assert
    expect(result).toBe(5);
  });

  it('returns the total for one booking', () => {
    // Arrange
    const bookings = [{ attendee: 'Ada', seats: 2 }];

    // Act
    const result = totalSeats(bookings);

    // Assert
    expect(result).toBe(2);
  });

  it('returns 0 for no bookings', () => {
    // Arrange
    const bookings: Booking[] = [];

    // Act
    const result = totalSeats(bookings);

    // Assert
    expect(result).toBe(0);
  });
});

src/booking.test.ts — segment 2

Append these case arrays directly after segment 1. Their object rows use label, input, and expected where a returned value is compared.

typescript
const acceptedCases: {
  label: string;
  input: unknown;
  expected: { attendee: string; seats: number };
}[] = [
  {
    label: 'a trimmed attendee',
    input: { attendee: '  Ada  ', seats: 2, note: 'window seat' },
    expected: { attendee: 'Ada', seats: 2 }
  },
  {
    label: 'lowest seat count',
    input: { attendee: ' Ada ', seats: 1 },
    expected: { attendee: 'Ada', seats: 1 }
  },
  {
    label: 'highest seat count',
    input: { attendee: 'Lin', seats: 6 },
    expected: { attendee: 'Lin', seats: 6 }
  }
];

const rejectedCases: { label: string; input: unknown }[] = [
  { label: 'null', input: null },
  { label: 'an array', input: [] },
  { label: 'missing seats', input: { attendee: 'Ada' } },
  { label: 'a string attendee', input: { attendee: 12, seats: 2 } },
  { label: 'a whitespace attendee', input: { attendee: '   ', seats: 2 } },
  { label: 'a string seat count', input: { attendee: 'Ada', seats: '2' } },
  { label: 'seat count 0', input: { attendee: 'Ada', seats: 0 } },
  { label: 'seat count 7', input: { attendee: 'Ada', seats: 7 } },
  { label: 'a non-integer seat count', input: { attendee: 'Ada', seats: 2.5 } }
];

src/booking.test.ts — segment 3

Finally, append this parser suite after the case arrays. The two bodies remain short because each group has one assertion pattern.

typescript
describe('parseBooking', () => {
  it.each(acceptedCases)('accepts $label', ({ input, expected }) => {
    // Act
    const booking = parseBooking(input);

    // Assert
    expect(booking).toEqual(expected);
  });

  it.each(rejectedCases)('rejects $label', ({ input }) => {
    // Assert
    expect(() => parseBooking(input)).toThrow('Invalid booking');
  });
});

Prevent order-dependent tests

Keep changed state local

A unit test should produce the same result when it runs alone or with the complete suite. A mutable array declared outside individual tests can couple cases: one test changes the data that another test assumes is unchanged.

Construct the bookings array and its booking objects inside each ordinary total test. If a parameterized test must change row data, make a fresh array and fresh booking objects first. Parameterization repeats a test pattern; it does not clone row objects or reset mutations for you.

Replace a shared mutable fixture

The first pair is coupled because both cases use and change the same array. Replace that pattern with the second pair, which constructs inputs inside each test. No lifecycle hook is needed.

typescript
const sharedBookings = [{ attendee: 'Ada', seats: 2 }];

it('adds a booking', () => {
  sharedBookings.push({ attendee: 'Lin', seats: 3 });
  expect(totalSeats(sharedBookings)).toBe(5);
});

it('returns the total for one booking', () => {
  expect(totalSeats(sharedBookings)).toBe(2);
});

it('adds a booking independently', () => {
  const bookings = [{ attendee: 'Ada', seats: 2 }];
  bookings.push({ attendee: 'Lin', seats: 3 });
  expect(totalSeats(bookings)).toBe(5);
});

it('returns the total for one booking independently', () => {
  const bookings = [{ attendee: 'Ada', seats: 2 }];
  expect(totalSeats(bookings)).toBe(2);
});

State ownership patterns

Compare these setup choices before writing a test that changes bookings.

The independent pattern keeps mutations inside the current test execution.
PatternWhat changesResult
Shared row inputA prior case can change the stored array or a booking object.A later case may depend on run order.
Fresh local valuesOnly the current case changes its copied array and copied objects.The case can run alone or with the suite.
Empty collection caseA new typed empty array is created inside its named test.Its required result stays explicit: 0.

Apply rows and fresh inputs

Practice: parameterize without sharing state

Complete the structural assertion and the fresh-input expression. Each parameterized run must use its current row values, and the total test must not mutate a row's stored input.

typescript
const acceptedRows = [
  {
    label: 'a trimmed attendee',
    input: { attendee: ' Lin ', seats: 3 },
    expected: { attendee: 'Lin', seats: 3 }
  }
];

const totalRows = [
  {
    label: 'updates Ada and appends a booking',
    input: [{ attendee: 'Ada', seats: 1 }],
    expected: 5
  },
  {
    label: 'updates Lin and appends a booking',
    input: [{ attendee: 'Lin', seats: 2 }],
    expected: 6
  }
];

it.each(acceptedRows)('accepts $label', ({ input, expected }) => {
  const booking = parseBooking(input);
  expect(booking).(expected);
});

it.each(totalRows)('returns $expected for $label', ({ input, expected }) => {
  const bookings = ;
  bookings[0].seats += 1;
  bookings.push({ attendee: 'Lin', seats: 3 });
  expect(totalSeats(bookings)).toBe(expected);
});

Step 7 of 8

Diagnose a Failure and Preserve the Regression Test

Trace an empty-input failure from a Vitest assertion to a faulty accumulator, repair the local implementation, and retain the reproducing regression test.

Reproduce and read the failure

Reproduce the reported behavior

A failing unit test is evidence, not an instruction to edit the assertion. Reproduce the reported defect while keeping the named empty-input test unchanged: its contract says an empty booking collection has a total of 0.

In src/booking.ts, temporarily use the faulty revision below for totalSeats, then run the complete suite in single-run mode. This revision adds 1 to every result, including the empty collection.

Faulty local revision

Temporarily replace only the totalSeats function with this faulty revision. Leave parseBooking and the cumulative tests from the previous step in place.

typescript
export function totalSeats(bookings: Booking[]): number {
  return bookings.reduce((total, booking) => total + booking.seats, 1);
}

Single-run failure report

Run the full local suite once. The faulty accumulator affects all three totalSeats cases; the parser cases still pass. Exact timing, Vitest version, and absolute paths can vary.

bash
npm run test:run
Expected output
> vitest-booking-lab@1.0.0 test:run
> vitest run

 RUN  v2.1.0

 ❯ src/booking.test.ts (15 tests | 3 failed)
   × totalSeats > returns total seats for standard bookings
     → expected 6 to be 5
   × totalSeats > returns the total for one booking
     → expected 3 to be 2
   × totalSeats > returns 0 for no bookings
     → expected 1 to be +0

 FAIL  src/booking.test.ts > totalSeats > returns 0 for no bookings
AssertionError: expected 1 to be +0

- Expected
+ Received

- 0
+ 1

 ❯ src/booking.test.ts:38:20

 Test Files  1 failed (1)
      Tests  3 failed | 12 passed (15)

The source location identifies where Vitest observed the mismatch in the cumulative test file. It does not automatically identify the production-code defect.

Interpret, then verify

Trace evidence toward its cause

Diagram connecting an empty-input assertion, an Expected 0 and Received 1 failure, and a reduce accumulator initialized to 1.

The assertion location shows where the unexpected behavior was observed. Follow the returned value back into the implementation to find its cause.

Interpret the assertion difference

The report for returns 0 for no bookings shows Expected 0 and Received 1. What can you conclude before changing any code?

Use a disciplined repair workflow

Order the investigation

Put this diagnosis-and-repair workflow in order.

  1. Inspect the implementation that produced the received value.
  2. Read the failing suite and case name, comparison, and source location.
  3. Correct the production defect if the contract and test setup are valid.
  4. Check the written contract, the empty fixture, and the expected value.
  5. Rerun the full suite and retain the empty-input regression test.

Correct the defect

Warning

Keep the evidence

Do not make a failing suite green by changing the expected value to match the defect, deleting the assertion, or skipping the case. A reproducing test is valuable evidence: after the repair, keep it as a regression test.

Repair without weakening the test

After verifying the contract and the empty fixture, repair the accumulator. Fill the blank so this function satisfies the retained empty-input regression test while continuing to add each booking's seats.

typescript
export function totalSeats(bookings: Booking[]): number {
  return bookings.reduce((total, booking) => total + booking.seats, );
}

Restore and verify the local suite

Apply the repair locally

The interactive exercise does not edit your local project. Now replace the faulty totalSeats function in src/booking.ts with the corrected version below. Leave it('returns 0 for no bookings', ...) unchanged in src/booking.test.ts; it is the regression test that reproduces the defect if the accumulator is changed again.

Corrected totalSeats

In src/booking.ts, restore this implementation. Keep the existing Booking type and parseBooking export in the same file.

typescript
export function totalSeats(bookings: Booking[]): number {
  return bookings.reduce((total, booking) => total + booking.seats, 0);
}

Verify the retained regression test

Save src/booking.ts, then rerun the complete suite in single-run mode. All 15 current tests should pass before you continue to the final review.

bash
npm run test:run
Expected output
> vitest-booking-lab@1.0.0 test:run
> vitest run

 RUN  v2.1.0

 ✓ src/booking.test.ts (15 tests) 8ms

 Test Files  1 passed (1)
      Tests  15 passed (15)
   Start at  10:24:18
   Duration  412ms

Timing and Vitest version can differ on your computer. The important result is 15 passing tests, including `returns 0 for no bookings`.

Step 8 of 8

Complete and Review the Logic-Test Suite

Finish the local booking logic-test suite, run its two separate checks, and explain what their results do and do not demonstrate.

Audit and extend the suite

Make the audit executable

Treat this final review as an executable audit. Your completed src/booking.test.ts should contain three named totalSeats tests: the standard total of 5, one valid booking, and returns 0 for no bookings. Keep the empty-collection case unchanged: it is the regression test for the accumulator defect.

For parseBooking, retain accepted seat endpoints 1 and 6; rejected values 0, 7, and a non-integer; and representative malformed unknown inputs. Then add one new ordinary valid record and one new malformed record. The completed suite below has 17 tests: 3 total tests, 4 accepted parser cases, and 10 rejected parser cases.

Evidence from separate checks

A workflow connecting booking contract cases to assertions, a test result, and a separate type-check result.

Representative runtime cases and static type analysis provide different evidence.

Final suite checklist

Use this list to compare your local file before you run the final commands.

Required evidence in the cumulative booking suite
AreaCases to retain or addEvidence
TotalsStandard total, one booking, empty collectionPrimitive result
RegressionEmpty collection still returns 0Named regression test
Accepted parserOrdinary valid record; seats 1 and 6Structured result
Rejected boundariesSeats 0, 7, and a non-integerExpected exception
Malformed recordsnull, array, missing field, wrong types, whitespace attendeeExpected exception
IndependenceNo test relies on another test changing dataFresh or unmodified inputs

Complete every parser category

Accepted parser cases

Replace the existing acceptedCases declaration with this complete version. Keep the existing it.each(acceptedCases) test body below it; do not add another import or suite.

typescript
const acceptedCases: {
  label: string;
  input: unknown;
  expected: { attendee: string; seats: number };
}[] = [
  {
    label: 'a trimmed attendee',
    input: { attendee: '  Ada  ', seats: 2, note: 'window seat' },
    expected: { attendee: 'Ada', seats: 2 }
  },
  {
    label: 'lowest seat count',
    input: { attendee: ' Ada ', seats: 1 },
    expected: { attendee: 'Ada', seats: 1 }
  },
  {
    label: 'highest seat count',
    input: { attendee: 'Lin', seats: 6 },
    expected: { attendee: 'Lin', seats: 6 }
  },
  {
    label: 'an ordinary seat count',
    input: { attendee: '  Nia  ', seats: 4 },
    expected: { attendee: 'Nia', seats: 4 }
  }
];

Rejected parser cases

Replace the existing rejectedCases declaration with this complete version. It preserves every contract category and adds a malformed record not used earlier. The existing parameterized rejection test will run every row.

typescript
const rejectedCases: { label: string; input: unknown }[] = [
  { label: 'null', input: null },
  { label: 'an array', input: [] },
  { label: 'missing seats', input: { attendee: 'Ada' } },
  { label: 'a string attendee', input: { attendee: 12, seats: 2 } },
  { label: 'a whitespace attendee', input: { attendee: '   ', seats: 2 } },
  { label: 'a string seat count', input: { attendee: 'Ada', seats: '2' } },
  { label: 'a null seat count', input: { attendee: 'Nia', seats: null } },
  { label: 'seat count 0', input: { attendee: 'Ada', seats: 0 } },
  { label: 'seat count 7', input: { attendee: 'Ada', seats: 7 } },
  { label: 'a non-integer seat count', input: { attendee: 'Ada', seats: 2.5 } }
];

Verify the parameterized assertions

Complete the two assertion patterns used by the case arrays. These bodies belong inside the existing describe('parseBooking', ...) suite.

typescript
it.each(acceptedCases)('accepts $label', ({ input, expected }) => {
  const booking = parseBooking(input);
  expect(booking).(expected);
});

it.each(rejectedCases)('rejects $label', ({ input }) => {
  expect().('Invalid booking');
});

Run distinct checks and interpret evidence

Run the final local checks

Save src/booking.ts and src/booking.test.ts, then run both checks from the vitest-booking-lab folder. With the three total tests and the case arrays on the previous screen, Vitest runs 17 tests.

bash
npm run test:run
npm run typecheck
Expected output
> vitest-booking-lab@1.0.0 test:run
> vitest run

 RUN  v3.2.4 /Users/learner/projects/vitest-booking-lab

 ✓ src/booking.test.ts (17 tests) 6ms

 Test Files  1 passed (1)
      Tests  17 passed (17)
   Start at  10:42:18
   Duration  352ms (transform 52ms, setup 0ms, collect 46ms, tests 6ms, environment 0ms, prepare 78ms)

> vitest-booking-lab@1.0.0 typecheck
> tsc --noEmit

This is one concrete sample. Your Vitest version, path, timings, start time, and test count can differ if your suite contains additional cases. After the npm script lines, a successful `tsc --noEmit` run normally prints no diagnostics.

Tip

Interpret failures before changing code

A passing npm run test:run means the selected runtime examples match their asserted contracts. A passing npm run typecheck means TypeScript found no static errors under this project configuration. If either check fails, investigate the contract, fixture, expected value, and implementation rather than weakening or removing a test.

Summary

What the completed suite demonstrates

Keep this workflow for future logic changes.

  • Derive representative unit test cases from an observable contract, including ordinary, boundary, empty, and malformed-input categories.
  • Use structural comparison for returned Booking objects and an invoked callback for expected parser exceptions.
  • Keep test inputs independent; do not rely on another case's mutations or execution order.
  • Retain the empty-collection regression test after correcting the accumulator defect.
  • Run the unit-test suite and tsc --noEmit separately because they provide different evidence.
  • Passing logic tests do not establish React interactions, browser behavior, or every possible input outcome.

Report your local evidence

Final local review

After completing the assertions and running both commands on your computer, write 3–6 sentences. Report: (1) the new valid parser case and new malformed parser case you added, (2) the observed result of npm run test:run and the separate result of npm run typecheck, and (3) the retained regression test name and its expected outcome. If a check failed, state what you investigated and what you changed before rerunning it.

Write at least 220 characters (0/220).

Step complete

Congratulations! You completed: Test TypeScript Logic with Vitest

You completed and reviewed an independent Vitest suite for TypeScript booking logic.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate