Frontend Testing Strategies: Trophy, RTL, MSW & Playwright
A modern frontend testing strategy follows the Testing Trophy (static analysis, unit tests, integration tests via RTL + MSW, and E2E smoke tests via Playwright) to maximize confidence while minimizing maintenance overhead.
Why it matters in interviews
Visual & Interactive Explanation
Testing Pyramid vs Modern Testing Trophy
Traditional Pyramid
Unit-heavy legacy approach
Testing Trophy
Integration-heavy modern standard
Code Examples & Implementation
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
test("submits credentials and displays welcome message", async () => {
const user = userEvent.setup();
render(<LoginForm onLoginSuccess={vi.fn()} />);
// Query by accessible roles (never use classnames or component state)
const usernameInput = screen.getByRole("textbox", { name: /username/i });
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole("button", { name: /sign in/i });
// Simulate real user keystrokes and click
await user.type(usernameInput, "alice@example.com");
await user.type(passwordInput, "Secret123!");
await user.click(submitButton);
// Assert observable DOM changes
expect(
await screen.findByRole("heading", { name: /welcome back, alice/i })
).toBeInTheDocument();
});import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
export const handlers = [
// Intercept GET /api/user
http.get("/api/user", () => {
return HttpResponse.json({ id: "1", name: "Alice", role: "admin" });
}),
// Intercept POST /api/checkout with failure override in specific test
http.post("/api/checkout", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ orderId: "ORD-999", status: "confirmed" });
}),
];
export const server = setupServer(...handlers);import { test, expect } from "@playwright/test";
export class CheckoutPage {
constructor(private page: import("@playwright/test").Page) {}
async navigate() {
await this.page.goto("/checkout");
}
async fillShippingDetails(address: string) {
await this.page.getByRole("textbox", { name: /address/i }).fill(address);
}
async placeOrder() {
await this.page.getByRole("button", { name: /place order/i }).click();
}
}
test("completes checkout journey", async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.navigate();
await checkout.fillShippingDetails("123 Main St");
await checkout.placeOrder();
await expect(page.getByText(/order confirmed/i)).toBeVisible();
});Interview-Ready Answers
Follow the Testing Trophy: prioritize integration tests with React Testing Library and MSW simulating real user interactions, backed by static TypeScript/ESLint checks and fast Playwright E2E smoke journeys.
Follow-up Questions & Deep Dives
🎯 Code Output — What Does This Print?
// Given this DOM:
// <button aria-label="Close dialog">X</button>
// What is the accessible query to find this button?
screen.getByRole("button", { name: "Close dialog" });
// Does screen.getByRole("button", { name: "X" }) work?Common Mistakes & Anti-Patterns
- Testing internal component state (e.g. `wrapper.state('open')`) rather than user-visible DOM changes
- Using `fireEvent.click()` instead of `@testing-library/userEvent` (skipping focus, blur, and pointer events)
- Mocking sub-components instead of testing them together in realistic integration tests
- Relying on brittle CSS selectors (e.g. `.container > div:nth-child(2)`) instead of accessible roles
Real-World Architectural Scenario
A team refactors a form component from 5 `useState` hooks to a single `useReducer`. 40 unit tests fail even though the UI works identically. Why did this happen and how do you prevent it?
Rate Your Readiness
Rate how comfortably you can explain this in an interview to update your global readiness gauge: