Skip to main content

Testing

One of the most important principles in postman-app is this: a green CI pipeline means you can merge and ship with confidence. If tests pass, the code is good to go. If they fail, you fix the code — not the tests.

That sounds obvious, but it requires a testing strategy where tests are actually trustworthy. Flaky tests — tests that sometimes pass and sometimes fail for no reason — are not tolerated. They are disabled immediately, because a flaky test is worse than no test: it trains engineers to ignore failures.

This page explains how testing works in postman-app, layer by layer.


Three levels of testing​

Think of testing as a pyramid. At the bottom is the biggest layer — fast, cheap unit tests that run on every change. At the top is the small, expensive layer — slow E2E tests that simulate a real user.

/\
/ \
/ E2E \ ← Playwright: real browser, full user flows
/--------\
/ Component \ ← React Testing Library: what users see and do
/ tests \
/----------------\
/ Unit tests \ ← Vitest: individual functions and hooks
/____________________\

Most of your tests will be unit and component tests. E2E tests are for critical user flows that would be catastrophic to break.


Level 1: Unit tests​

Unit tests test a single piece of code in isolation — one function, one hook, one store — without involving the rest of the application.

Where they live: Co-located with the code they test, inside each package.

data/workspace-data/
├── src/
│ ├── store.ts
│ ├── store.test.ts ← tests for store.ts, right next to it
│ └── __tests__/
│ └── controller.test.ts

Tool: Vitest.

How to run:

# Test a specific package
nx test @postman-app/workspace-data

# Test with coverage
nx test @postman-app/workspace-data --coverage

# Watch mode during development
nx test @postman-app/workspace-data --watch

Why decoupled packages have faster tests

This is subtle but important. When you test a coupled package (one that imports from src/renderer/), the test runner has to load an enormous number of auto-mock files from the monolith setup. We have thousands of these.

When you test a decoupled package, those auto-mocks are completely skipped. Tests are faster, cleaner, and easier to understand. This is one of the hidden benefits of the decoupling migration — it's not just about architecture, it directly makes tests better.


Level 2: Component tests​

Component tests test React components as a user would experience them — they render a component in a simulated browser environment and interact with it.

// workspace-overview-tab/src/__tests__/WorkspaceOverviewTab.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { WorkspaceOverviewTab } from '../components/WorkspaceOverviewTab';

it('shows the workspace description when loaded', async () => {
render(<WorkspaceOverviewTab workspaceId="ws-123" />);

await screen.findByText('My workspace description');
expect(screen.getByText('My workspace description')).toBeInTheDocument();
});

These live in the same co-located test directories as unit tests. The difference is in what they test: not "does this function return the right value" but "does the user see the right thing when they open this tab".

Tool: React Testing Library + Vitest.

Best practice: Test user behavior, not implementation details. "The user clicks Save and sees a success message" — not "ComponentStore.save() was called once with the right arguments."


Level 3: E2E tests​

E2E tests launch the actual Postman app in a browser and click through it like a real user. They are the most confidence-giving tests — if they pass, you know the real product works — but they are also the slowest and most fragile.

Where they live: tests-v2/e2e-tests/ — the current standard, using Playwright.

tests-v2/e2e-tests/
└── specs/
└── workspace/
└── workspace-overview.spec.ts ← tests for the workspace overview page

The old system: tests/e2e-tests/ uses @postman/teleport, an internal wrapper around WebdriverIO (WDIO). This still exists but is being replaced by Playwright. Write all new E2E tests in tests-v2/.

How Playwright tests work:

// tests-v2/e2e-tests/specs/workspace/workspace-overview.spec.ts
import { test, expect } from '@playwright/test';

test('user can see workspace description on overview tab', async ({ page }) => {
await page.goto('/workspace/ws-123');
await page.click('[data-testid="overview-tab"]');
await expect(page.getByText('My workspace description')).toBeVisible();
});

When E2E tests run: Not on every PR — they are too slow. They run selectively based on what changed, and in full on the nightly build and before production releases.


How CI decides what to test​

This is one of the most important ideas in the testing strategy: only test what changed.

The monolith approach (old): every PR runs the entire test suite. Takes 30+ minutes.

The Nx approach (new): CI looks at what files changed, traces the dependency graph, and only tests the packages that are actually affected.

Example: You change a utility function in libs/dock-constants. CI traces which packages import from it — maybe platform-libs/sidebar and ui-features/collections-sidebar — and only runs tests for those packages. Everything else is skipped.

libs/dock-constants (changed)
↓
platform-libs/sidebar (affected → test it)
↓
ui-features/collections-sidebar (affected → test it)

ui-features/ai-chat (not affected → skip ✓)
data/workspace-data (not affected → skip ✓)

This is called nx affected and it's why CI can be fast even on a 250+ package monorepo.

Currently, the pre-integration check (the main CI gate for every PR) takes 7–11 minutes. The team's goal is to get it under 5 minutes by December 2026 through Rspack v2, TypeScript v7, and completing the Vitest migration.


What CI checks on every PR​

When you open a PR, this is what runs automatically:

CheckToolWhat it does
Code formattingPrettierChecks only the files you changed
LintingESLintRuns only on affected packages
TypeScripttscTypechecks only affected packages
Unit testsVitestRuns only for affected packages (nx affected)
Bundle sizeStatoscopeFails if the critical path bundle grows by more than 5kB
Circular dependency checkCustom scriptEnsures you haven't created new circular imports

All of these must be green before your PR can merge.


Async tests: act(), waitFor, and fake timers​

React 18 batches updates automatically and flushes effects differently from React 16, which makes hand-rolled "settle the component" tricks unreliable. The rules below are what the repo standardised on when React 18 became the baseline. They apply to both unit and component tests under Vitest.

Two facts to anchor on:

  • Import act from @testing-library/react, not react-dom/test-utils (deprecated in React 18) — RTL re-exports whichever act the installed React provides. A few specs still use the deprecated import; convert them when you next touch one.
  • testing-library/no-unnecessary-act is error by default for test files, via plugin:testing-library/react in the root .eslintrc.js. A package can override it and platform-ui/aether-components does, but almost everywhere redundant wrapping is a lint failure rather than a judgement call.

Don't wrap what RTL already wraps​

render, rerender, unmount, fireEvent, userEvent.*, waitFor and the findBy* queries all wrap their work in act() internally. Wrapping them again buys nothing.

// ✗ lint error, and the inner act does nothing
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
});

// ✓
fireEvent.click(screen.getByRole('button', { name: 'Save' }));

Wait for state with waitFor / findBy*, not a microtask flush​

The most common React-16-era pattern is a one-tick flush standing in for "the async work finished":

// ✗ resolves exactly one microtask. Add an await anywhere in the hook's promise
// chain and this starts failing for reasons that have nothing to do with the change.
await act(async () => {
await Promise.resolve();
});
expect(result.current.isLoading).toBe(false);

// ✓ retries until the state actually arrives, or fails with a real timeout
await waitFor(() => expect(result.current.isLoading).toBe(false));

Same idea in the DOM:

// ✗
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId('jira-issues')).toBeInTheDocument();

// ✓
expect(await screen.findByTestId('jira-issues')).toBeInTheDocument();

If the awaited value is genuinely synchronous — a mocked hook returning a literal, a rerender — drop the flush entirely rather than converting it. A tick that gates nothing is noise.

Do wrap updates you trigger yourself​

act() is still required when you poke React from outside an RTL API — calling a hook's setter, resolving a deferred, emitting on a store. Wrap the trigger synchronously, then wait for the result:

// ✓
act(() => {
result.current.updateStatus(1, Status.Approved);
});

await waitFor(() =>
expect(result.current.items.find((i) => i.id === 1)?.status).toBe(
Status.Approved
)
);

Two hard rules here:

  • Never await inside a synchronous act(() => …) callback. The returned promise is dropped on the floor, so the work escapes the act scope and the update lands unwrapped. Either make the callback async and await act(…), or move the await outside.
  • Always await an async act(). act(async () => …) returns a thenable; ignoring it leaves a floating update that surfaces as a warning — or a failure — in an unrelated test.

And never nest act() inside another act(). If you find yourself reaching for it, the inner call is the one to delete.

The two cases where await act() is still the right answer​

1. Invariant assertions. When the assertion is that something didn't happen — a poll that stopped, a callback that wasn't re-fired, a diagnostic logged exactly once — waitFor is the wrong tool: it passes on its first attempt, before the thing you're ruling out had a chance to occur. You need a deterministic settle point instead.

// ✓ the diagnostic is logged during render, so it has already had its chance
render(experiment());
await act(async () => undefined);

expect(errorSpy).toHaveBeenCalledTimes(1);

The catch: an empty act() drains the microtasks that are already queued and nothing more. It will not fire a pending timer or settle an unresolved deferred, so if the behaviour you are ruling out is waiting on one of those, the assertion passes without that behaviour ever getting a chance to run — a green test proving nothing. Drive the thing first, then assert:

// ✗ the retry is scheduled 5s out, so this proves only that it hasn't fired *yet*
await act(async () => undefined);
expect(fetchSpy).toHaveBeenCalledTimes(1);

// ✓ advance past the window it would have fired in, then assert it stayed put
await act(async () => {
await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS);
});
expect(fetchSpy).toHaveBeenCalledTimes(1);

2. Fake timers. waitFor advances the clock itself when fake timers are installed, so it can fire the very interval the test is trying to control. Advance explicitly, and use the async timer API:

// ✗ advances every timer synchronously, then flushes exactly one microtask —
// so a poll whose promise chain has two awaits is still mid-flight
await act(async () => {
vi.advanceTimersByTime(POLL_INTERVAL_MS);
await Promise.resolve();
});

// ✓ advanceTimersByTimeAsync flushes the microtasks each timer callback queues
await act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
});

Name the settle when you repeat it​

A file that needs the same wait in a dozen places should say so once. A named helper documents the intent and gives you one place to fix:

// Fake timers are installed, so waitFor would drive the poll interval itself.
const advancePoll = () =>
act(async () => {
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS);
});

Quick reference​

SituationUse
Click, type, render, rerender, unmountThe RTL API bare — no act()
Waiting for state, text, or a call to arrivewaitFor(…) / await screen.findBy*
Calling a hook setter or store method directlyact(() => …), then waitFor for the result
Awaiting a promise you resolve in the testawait act(async () => { await deferred; })
Asserting something didn't happenawait act(async () => undefined), then assert
Driving fake timersawait act(async () => { await vi.advanceTimersByTimeAsync(n); })
Nothing async involvedNo act(), no flush

Writing a good test: the philosophy​

From the team's experience, the most important rule is this: the problem is usually not the code, it's the testing.

Teams that ship confidently have good test coverage. Teams that are afraid to refactor usually don't. Here's what makes tests good in postman-app:

Test behavior, not implementation. If you refactor the internals but the behavior stays the same, the tests should still pass. Tests that break on refactors are testing the wrong thing.

Make tests deterministic. A test that sometimes passes and sometimes fails is worse than no test. It trains engineers to ignore CI failures.

Keep unit tests close to the code. Co-location (store.ts next to store.test.ts) means tests are less likely to go stale. When you delete a file, the test goes with it.

E2E tests for critical paths only. The workspace overview loading. The collection runner working. The request sending and getting a response. Not every edge case — that's what unit tests are for.


Testing the Nx layers​

Different layers have different testing approaches because their responsibilities are different:

LayerWhat to testHow
libs/Pure functions and utilitiesUnit tests — no mocking needed, just inputs and outputs
data/Store state transitions, API call shapesUnit tests with mocked API responses
platform-libs/Service initialization, event handlingUnit tests with mocked browser APIs
ui-features/Component rendering, user interactionsReact Testing Library component tests
views/Tab navigation, permission-gated visibilityComponent tests + E2E for critical paths
E2EFull user journeys (sign in, create collection, run request)Playwright in tests-v2/

References​