Rubin Aga
Writing

Testing a large React app

Landscape's frontend is the highest-scoring frontend at Canonical, with a 98.5% TQI score and 99.7% code coverage. Here's how it's tested with Vitest, MSW and Playwright.

Landscape’s frontend is the highest-scoring frontend application at Canonical. On the TICS dashboard it has a total TQI score of 98.5% and a code coverage score of 99.7%.

TQI is the TIOBE Quality Indicator, the overall code-quality score that TICS gives a project. It combines several measurements into one percentage: test coverage, how complex the functions are, compiler warnings, how well the code follows coding standards, duplicated code, how many other parts each piece depends on, and security issues. Canonical tracks its projects on TICS, which makes it easy to compare one codebase against another.

It’s about 90,000 lines of code, split across around 50 features: instances, packages, repositories, access groups, alerts and so on. That isn’t huge, but there’s a lot in it, and there are nearly 700 test files. The code is open source, so everything below can be checked.

How the code is laid out

The code is organised by feature, and each component gets its own folder:

src/features/instances/components/AccessGroupFilter/
  AccessGroupFilter.tsx
  AccessGroupFilter.test.tsx
  constants.ts
  index.ts

The test always sits next to the component it covers. When you change a component, its test is right there, and when you delete one, the test goes with it. Nobody has to hunt through a separate tests/ tree to find out what’s covered.

The three layers

We added testing early in the rebuild, and it runs in three layers.

Most tests are unit and component tests in Vitest, which I upgraded to version 4 at the start of 2026. They use React Testing Library to click around the way a user would. A typical one renders a component, opens it, picks an option and checks what happened:

it("selects an access group", async () => {
  renderWithProviders(<AccessGroupFilter {...props} />);
  await userEvent.click(screen.getByRole("button"));
  const firstCheckbox = screen.getByRole("checkbox", { name: options[0].label });
  await userEvent.click(firstCheckbox);

  expect(firstCheckbox).toBeChecked();
  expect(props.onChange).toHaveBeenCalled();
});

A lot of the coverage comes from tests like this one. In spring 2026 I did two big pushes on it, using a swarm of GitHub Copilot agents running the frontier model at the time. The first push covered every file outside profiles that was under 60% coverage, 114 files and about 7,500 lines of tests. The second did the same for 133 more component files, which came to about 12,000 lines.

Under those, MSW fakes the backend, so any component that fetches data gets a realistic response without a server running.

On top, Playwright drives a real browser. We use it less, only on a handful of critical paths, mostly authentication and login. Those are the flows where a break hurts most, and where a unit test can’t tell you the whole thing works end to end.

How the mocks work

Mocking the backend with MSW was Yurii Vasyliev’s idea. He’s an excellent senior engineer on the UI team, and he brought MSW into the test setup in March 2024. Making it work properly across the codebase was a team effort, and we’ve kept iterating on it since. It’s been a game changer for us, in tests and in day-to-day development.

There’s a handler file for each area of the API, around 50 of them, with mock data next to them. Each handler answers a request the way the real backend would. It can also be told to misbehave. I did a lot of the work on a small controller that lets a test set the status of any endpoint before it runs:

  • default: the normal mock data.
  • empty: an empty list.
  • error: the request fails.
  • variant: a response the test passes in.

So a test can check what the page looks like when a request fails, or when a list is empty, without writing a new mock each time. That’s how we get at edge cases that are hard to produce from a real backend. Later I went back and removed the mocks we no longer needed across 100 test files.

The same handlers run in the browser during development. Some endpoints only exist for specific tests, so we added guards to keep them out of the demo, which runs against a backend server we keep for the frontend. None of the mock data ever reaches staging or production.

Not waiting on the backend

In development, MSW meant we stopped waiting. The backend team was often slow to deliver some endpoints. Now we agree on the schema with them at the start, write a handler for it, and build our part against the mock.

I honestly can’t think of much that’s annoying about it.

The browser tests

The Playwright tests work differently from the rest. They run against the real app with MSW switched off, because their job is to prove the whole thing works with nothing faked in between.

For a long time they weren’t doing that job. The tests were there, but they were always failing, so nobody relied on them. I fixed and stabilised them, moved them into feature folders, added new specs and reworked how they run in CI. Now they pass, and when one fails, it means something.

Landscape comes in two flavours: SaaS, which Canonical hosts, and self-hosted, which companies run themselves. Some behaviour differs between them, so the browser tests are split into two projects, and each test is tagged @saas, @self-hosted or both. One run checks both versions.

Most specs share a fixture that hands them a page that’s already logged in, with the welcome modal closed. Without it, every test would start by repeating the login form, and a change to login would break all of them at once. The fixture also records console errors and failed requests for each test, and when a test fails on retry, Playwright keeps a trace and a video, so you can see exactly what the browser did.

Separately, a teammate set up integration and API contract tests that run against a real backend in Docker. These check that the backend still answers the way the frontend expects.

Finding what isn’t tested

MSW has a side effect we use: since every request in the unit tests goes through a handler, we know exactly which endpoints the frontend calls. The same teammate built a weekly check on top of that. It compares the endpoints the frontend uses with the ones the backend contract tests cover, and lists the gaps. Then an LLM drafts Playwright specs for the five gaps that matter most.

The drafts aren’t merged as they are. Someone reviews each one, checks the assertions and the seed data, and runs it against the Docker backend before committing it. Still, every week there’s an up-to-date list of untested endpoints, with a draft test for each of the top five.

Linked from

  • What my first job taught me about processAt Canonical it's very different. We have proper code reviews and standups, and testing was part of the project from early on, which I wrote about in [Testing a large React app].

Terms in this post