// 1. Unit Test (Testing a pure function)
test('add', () => {
expect(add(1, 2)).toBe(3);
});
// 2. Integration Test (Testing Component + Child)
test('renders user list', () => {
render(<UserList users={['Alice', 'Bob']} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// 3. E2E Test (Playwright/Cypress)
test('login flow', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'user@test.com');
await page.click('#submit');
await expect(page).toHaveURL('/dashboard');
});
// In a CI/CD pipeline:
// - Run Unit Tests on every commit (Fast, < 1min)
// - Run Integration Tests on PR creation (Medium, ~5min)
// - Run E2E Tests before merging to main or nightly (Slow, ~30min)
Mocha: Flexible but requires manual setup. You need to install separate libraries for assertions (Chai), mocking (Sinon), and coverage (Istanbul). It is often seen in older Node.js projects.
Jest / Vitest: "Batteries Included". They come with assertions (expect), mocking, and coverage built-in. Vitest is the modern choice for Vite projects as it shares the same configuration.
Interview Tip: If asked about Mocha, say: "I am proficient with Vitest/Jest, which share similar syntax (describe/it/expect). I understand Mocha requires external assertion libraries like Chai, but the core testing concepts remain the same."
// ❌ Bad (Implementation detail)
screen.getByTestId('submit-btn');
// ✅ Good (Accessible role)
screen.getByRole('button', { name: /submit/i });
// If you change a <button> to a <div onClick> (bad practice),
// getByRole('button') will fail, alerting you to the accessibility regression.
// getByTestId would still pass, hiding the bug.
test('button is accessible', () => {
render(<CustomButton />);
// Ensures screen readers can find it
expect(screen.getByRole('button')).toBeEnabled();
});
Accessibility First: RTL philosophy is "The more your tests resemble the way your software is used, the more confidence they can give you."
getByRole('button', { name: /submit/i }) ensures the element is actually accessible as a button to screen readers. getByTestId is an implementation detail that users don't see.
// 1. getBy: Expect it to be there NOW
const title = screen.getByText('Welcome');
// 2. queryBy: Check if it is NOT there
const error = screen.queryByText('Error');
expect(error).toBeNull();
// 3. findBy: Wait for it to appear (Async)
const data = await screen.findByText('Loaded Data');
test('modal opens and closes', async () => {
// Modal is closed initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Open modal
await userEvent.click(screen.getByRole('button', { name: /open/i }));
// Wait for animation/render
const modal = await screen.findByRole('dialog');
expect(modal).toBeVisible();
});
// 1. RED: Write test for non-existent function
test('sum', () => {
expect(sum(2, 3)).toBe(5); // Fails: sum is not defined
});
// 2. GREEN: Write minimal code to pass
const sum = (a, b) => a + b;
// 3. REFACTOR: Improve code (if needed)
const sum = (a: number, b: number) => a + b;
// TDD is great for utility libraries or complex algorithms.
// Example: Building a currency formatter.
// 1. Test: format(1000, 'USD') -> '$1,000.00'
// 2. Implement basic logic.
// 3. Test: format(1000, 'EUR') -> '€1,000.00'
// 4. Update logic to handle locales.
A development process where you write the test before the code.
// Component
useEffect(() => {
fetch('/api/user').then(res => res.json()).then(setData);
}, []);
// Test
test('loads user data', async () => {
// Mock fetch
global.fetch = vi.fn(() => Promise.resolve({
json: () => Promise.resolve({ name: 'John' })
}));
render(<UserProfile />);
// Wait for data
expect(await screen.findByText('John')).toBeInTheDocument();
});
// Using MSW (Mock Service Worker) - Preferred
import { server } from './mocks/server';
import { rest } from 'msw';
test('handles server error', async () => {
server.use(
rest.get('/api/user', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserProfile />);
expect(await screen.findByText(/error loading/i)).toBeInTheDocument();
});
You must Mock the network request.
vi.spyOn(global, 'fetch') to mock the fetch function.await screen.findByText(...) to wait for the UI to update after the promise resolves.test('renders correctly', () => {
const { container } = render(<Button label="Click me" />);
expect(container).toMatchSnapshot();
});
// Creates a .snap file with the HTML structure
// Dangerous Scenario:
// You change a class name in a global style.
// 50 snapshots fail.
// You run `jest -u` (update snapshots) without looking.
// You accidentally broke the layout, but the test now passes with the broken layout.
//
// Use snapshots for small, stable components (like Icons or UI primitives).
It saves a serialized string of your DOM to a file. If the DOM changes, the test fails.
Danger: It's easy to ignore. Developers often just press "u" (update snapshot) without checking if the change was intentional, making the test useless.
import { renderHook, act } from '@testing-library/react';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
// Testing a hook that depends on Context
const wrapper = ({ children }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useCurrentUser(), { wrapper });
expect(result.current.isLoggedIn).toBe(false);
Use renderHook from @testing-library/react.
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
// ❌ Causes Warning
fireEvent.click(button); // Triggers async state update
// Test ends immediately, state updates after test finishes
// ✅ Fix
await userEvent.click(button); // userEvent wraps interactions in act() automatically
// OR
await waitFor(() => expect(screen.getByText('Updated')).toBeInTheDocument());
// Common in useEffect fetching
useEffect(() => {
fetchData().then(setState);
}, []);
// If you don't wait for the fetch to finish in the test,
// React tries to update state after the test tears down the component.
// Fix:
await screen.findByText('Data Loaded'); // Waits for the update
It means a state update happened (usually async) after your test finished assertions, or outside of React's call stack.
Fix: Use await waitFor(...) or findBy... to ensure the test waits for the update to finish before exiting.
// ❌ Fails: Could not find "store" in context
render(<ConnectedComponent />);
// ✅ Works: Wrap manually
render(
<Provider store={store}>
<ConnectedComponent />
</Provider>
);
// Create a custom render function (test-utils.js)
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
// In tests:
import { render } from './test-utils';
render(<ConnectedComponent />); // Automatically wrapped
You must wrap the component in the test with the same Provider.
Best Practice: Create a custom render function that automatically wraps everything in your global providers.
// Running coverage
npm test -- --coverage
// Output:
// Statements: 80%
// Branches: 70%
// Functions: 90%
// Lines: 80%
// 100% coverage forces you to test implementation details.
// Example: Testing a default parameter that is never used in the app.
//
// Focus on:
// 1. Critical Business Logic (Payments, Auth) -> 100%
// 2. UI Components -> Interaction tests
// 3. Utils -> Unit tests
It measures what percentage of your code lines were executed during tests.
Verdict: 100% is usually a waste of time (diminishing returns). Aim for ~80%, focusing on business logic and critical paths. Don't test simple getters/setters or third-party libraries.
// ❌ fireEvent: Dispatches a DOM event directly
fireEvent.change(input, { target: { value: 'hello' } });
// ✅ userEvent: Simulates full user interaction
await userEvent.type(input, 'hello');
// When a real user types into an input:
// 1. Focus event fires
// 2. KeyDown event fires
// 3. Input event fires
// 4. KeyUp event fires
//
// `fireEvent.change` ONLY fires the input event.
// `userEvent.type` fires ALL of them, triggering any side effects attached to focus/blur/keys.
userEvent simulates full user interactions (clicks, typing) more accurately than fireEvent. It triggers all the intermediate events (focus, blur, keydown, keyup) that a real browser would, whereas fireEvent only dispatches the specific event you called.
// The Component
const UserProfile = () => {
const { user } = useAuth(); // Will throw if Provider is missing
return <div>{user.name}</div>;
};
// The Test
test('renders user', () => {
render(
<AuthProvider value={{ user: { name: 'Alice' } }}>
<UserProfile />
</AuthProvider>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// Create a custom render function to avoid repetition
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
// Now you can just use:
customRender(<UserProfile />);
You must wrap the component in the necessary Provider during the test. A common pattern is to create a custom render function that automatically wraps every component with your global providers (Theme, Auth, Redux).
// Shallow (Enzyme style) - Conceptual
// <Parent>
// <Child /> <-- Child is NOT rendered, just a placeholder
// </Parent>
// Full Rendering (RTL style)
// <Parent>
// <div>Child Content</div> <-- Child is fully rendered
// </Parent>
Shallow Rendering: Renders a component one level deep. Child components are not rendered, only their placeholders.
Why Discouraged: It tests implementation details. If you refactor a big component into smaller sub-components, shallow tests might break even if the application works perfectly. Full rendering (Integration testing) gives more confidence that the app actually works for the user.
test('submits form', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@test.com');
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith({ email: 'test@test.com' });
});
// Testing validation errors
test('shows error on invalid email', async () => {
render(<LoginForm />);
await userEvent.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/email is required/i)).toBeVisible();
});
const user = userEvent.setup();
render(<Login />);
await user.type(screen.getByLabelText(/email/i), 'test@test.com');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith(...);
// fireEvent (Low Level)
fireEvent.change(input, { target: { value: 'a' } });
// Just sets the value. Doesn't trigger focus, keydown, keyup.
// userEvent (High Level)
await userEvent.type(input, 'a');
// Triggers: focus -> keydown -> keypress -> input -> keyup
// If you have a form that validates on 'blur' or 'keydown':
// fireEvent.change() might NOT trigger the validation.
// userEvent.type() WILL trigger it, matching real user behavior.
test('debugging', () => {
render(<App />);
screen.debug(); // Prints HTML to console
const btn = screen.getByRole('button');
screen.debug(btn); // Prints only the button HTML
});
// Using Testing Playground
screen.logTestingPlaygroundURL();
// Generates a URL. Open it in browser to see your DOM
// and get suggested queries (e.g., "screen.getByRole('heading')").
1. Use screen.debug() to print the current DOM to the console.
2. Use screen.logTestingPlaygroundURL() to get a link to an interactive sandbox.
3. Run the test in "watch mode" to see changes instantly.
// Option 1: findBy (Preferred for elements)
const item = await screen.findByText('Loaded Item');
// Option 2: waitFor (For assertions)
await waitFor(() => {
expect(mockFn).toHaveBeenCalled();
});
// Waiting for an element to disappear
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// OR
await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));
Use await screen.findBy... when waiting for an element to appear. Use await waitFor(() => expect(...)) when waiting for an assertion to pass (e.g., a function to be called).
// Flaky Test Example:
test('random fail', async () => {
// Relies on network speed being fast
await page.click('#submit');
// Fails if server takes > 100ms
expect(await page.innerText('#status')).toBe('Done');
});
// Fixing Flakiness:
// 1. Don't use fixed timeouts (wait(1000)).
// 2. Use assertions that retry (findBy, expect.toBeVisible).
// 3. Isolate state (reset DB between tests).
// 4. Mock external services (don't hit real APIs).
A test that produces different results with the same code. Causes: Network timing, animations, random data, shared state between tests. They destroy trust in the test suite.
test('auto-logout after 5s', () => {
vi.useFakeTimers();
render(<App />);
// Fast-forward time
act(() => {
vi.advanceTimersByTime(5000);
});
expect(screen.getByText('Logged out')).toBeInTheDocument();
vi.useRealTimers();
});
// Debounce testing
test('search debounces input', () => {
vi.useFakeTimers();
const onSearch = vi.fn();
render(<Search onSearch={onSearch} />);
userEvent.type(input, 'react');
// Should not call yet
expect(onSearch).not.toHaveBeenCalled();
// Fast forward debounce time
act(() => vi.runAllTimers());
expect(onSearch).toHaveBeenCalledWith('react');
});
Use Jest/Vitest Fake Timers.
vi.useFakeTimers();
vi.advanceTimersByTime(1000);
This allows you to fast-forward time instantly instead of waiting real seconds.
// Static Analysis (TypeScript)
const add = (a: number, b: number) => a + b;
add("1", 2); // Error: Argument of type 'string' is not assignable to 'number'.
// Catches this BEFORE running code.
// Testing (Jest)
test('add', () => {
expect(add(1, 2)).toBe(3);
});
// Verifies logic is correct.
// Use both!
// TypeScript ensures you don't pass null to a function expecting a string.
// Tests ensure that function returns the correct string format.
// ESLint ensures you don't leave unused variables or console.logs.
// Original Code
function isPositive(n) {
return n > 0;
}
// Mutant 1 (Generated by Stryker)
return n >= 0;
// If your test is: expect(isPositive(1)).toBe(true);
// It passes for BOTH original and mutant.
// The mutant "survived". You need a test case for 0.
// StrykerJS is a popular tool.
// It changes `+` to `-`, `true` to `false`, removes function calls.
// High mutation score = Robust tests.
// Low mutation score = Tests might be passing but not checking enough.
A technique where a tool (like Stryker) deliberately introduces bugs into your code (mutants) and runs your tests. If your tests still pass, the mutant "survived" (meaning your tests are weak). If a test fails, the mutant was "killed".
const wrapper = ({ children }) => (
<AuthProvider value={{ user: 'Alice' }}>
{children}
</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBe('Alice');
// Testing a Theme Hook
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>
});
expect(result.current.mode).toBe('dark');
const wrapper = ({ children }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
// Property based testing is a form of fuzzing.
// Generating random strings, huge numbers, emojis, nulls.
test('parser handles garbage', () => {
const randomInput = generateRandomString(1000); // "åß∂ƒ©˙∆..."
expect(() => parse(randomInput)).not.toThrow();
});
// Security Fuzzing:
// Sending malformed JSON or SQL injection strings to an API endpoint
// to see if it crashes or leaks data.
Automated software testing technique that involves providing invalid, unexpected, or random data as inputs to a computer program to find crashes or leaks.
// Class
class Calculator {
public add(a, b) { return this.validate(a) + this.validate(b); }
private validate(n) { return Number(n); }
}
// Test
// ✅ Test public API
expect(calc.add(1, 2)).toBe(3);
// ❌ Don't test private
// expect(calc.validate(1)).toBe(1);
// If you test private methods, you lock the implementation.
// If you later decide to rename `validate` to `parseNumber`,
// your test fails even though the public behavior (add) is still correct.
// This increases maintenance burden.
Generally No. Test the public interface (props, user interactions). Private methods are implementation details. If you test them, refactoring becomes hard because you have to update tests even if behavior didn't change.
// Playwright
test('mobile layout', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator('.hamburger-menu')).toBeVisible();
});
test('desktop layout', async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.locator('.hamburger-menu')).toBeHidden();
});
// In Unit Tests (Jest/Vitest)
// You can mock window.matchMedia
window.matchMedia = vi.fn().mockImplementation(query => ({
matches: query === '(max-width: 600px)',
// ...
}));
In Cypress/Playwright, you can set the viewport size: cy.viewport('iphone-6'). In Unit tests, it's harder, but you can mock window.matchMedia.
// Consumer (Frontend) defines expectation:
// "I expect GET /user/1 to return { id: 1, name: string }"
// Provider (Backend) verifies:
// "Does my endpoint actually return that?"
// Using Pact
// 1. Frontend runs test, generates a "Pact file" (JSON contract).
// 2. Pact file is uploaded to a broker.
// 3. Backend CI downloads the Pact file and replays requests against itself.
// 4. If Backend changed the API (e.g., renamed 'name' to 'fullName'), the build fails.
Ensures that the frontend and backend agree on the API structure (Contract). Tools like Pact verify that the provider (API) sends what the consumer (Frontend) expects.
// Stub: Replaces behavior completely
vi.fn().mockReturnValue(42);
// Spy: Listens to calls, can pass through to original
const spy = vi.spyOn(console, 'log');
console.log('hi');
expect(spy).toHaveBeenCalledWith('hi');
// Mock: Object with expectations
const mockUser = {
getName: vi.fn().mockReturnValue('Bob')
};
// Spying is useful when you want to verify a side effect
// without stopping it (or while stopping it).
// Example: Spy on window.open to ensure a link opens a new tab,
// but prevent the test runner from actually opening a window.
vi.spyOn(window, 'open').mockImplementation(() => {});
test('saves to local storage', async () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
render(<ThemeSwitcher />);
await userEvent.click(screen.getByText('Dark Mode'));
expect(setItemSpy).toHaveBeenCalledWith('theme', 'dark');
});
// JSDOM implements localStorage, so you can also check values directly:
expect(localStorage.getItem('theme')).toBe('dark');
// Remember to clear it between tests!
afterEach(() => {
localStorage.clear();
});
Since tests run in JSDOM (which supports localStorage), you can just use it. Or, mock the Storage prototype to assert calls.
// Happy Path
test('login success', () => {
mockLogin.mockResolvedValue({ token: '123' });
// ... assert redirect to dashboard
});
// Unhappy Path
test('login failure', () => {
mockLogin.mockRejectedValue(new Error('Invalid credentials'));
// ... assert error message is shown
});
// Developers often forget Unhappy Paths.
// - Network timeout?
// - Empty search results?
// - 500 Server Error?
// - Invalid file format upload?
// Testing these ensures your app fails gracefully.
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('should have no accessibility violations', async () => {
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Catches common errors:
// - Low color contrast
// - Missing alt text on images
// - Missing labels on inputs
// - Duplicate IDs
// Does NOT catch everything (e.g., logical tab order), manual testing still needed.
Use jest-axe to run automated accessibility checks on your rendered DOM.
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
# .github/workflows/test.yml
name: Run Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm install
- run: npm test
- run: npm run e2e
// Block merging if tests fail.
// Configure GitHub "Branch Protection Rules":
// "Require status checks to pass before merging" -> Select "test" job.
Tests should run automatically on every Pull Request (via GitHub Actions, Jenkins). If tests fail, the merge should be blocked.
// ❌ Bad: Snapshotting entire App
render(<App />);
expect(container).toMatchSnapshot();
// Result: 5000 line snapshot file. Impossible to review.
// ✅ Good: Snapshotting a specific prop variation
render(<Card variant="featured" />);
expect(container).toMatchSnapshot();
// Result: 20 line snapshot. Easy to see if class changed.
// Use "Inline Snapshots" for very small outputs.
expect(generateSlug('Hello World')).toMatchInlineSnapshot(`"hello-world"`);
// The snapshot is written directly into your test file, making it visible.
Keep snapshots small. Don't snapshot the whole <App />. Snapshot small, presentational components. Review snapshot diffs carefully in PRs.
// Playwright (E2E) - Recommended
await page.dragAndDrop('#source', '#target');
// React Testing Library (Unit) - Harder
fireEvent.dragStart(source);
fireEvent.dragEnter(target);
fireEvent.dragOver(target);
fireEvent.drop(target);
// Often requires mocking dataTransfer object.
// If using a library like `dnd-kit` or `react-beautiful-dnd`,
// they often expose specific testing utilities or instructions.
// E2E is usually more reliable for this interaction.
Difficult in JSDOM. Libraries like dnd-kit provide testing utilities. Often easier to test in E2E (Playwright) where real mouse events exist.
import fc from 'fast-check';
test('addition is commutative', () => {
fc.assert(
fc.property(fc.integer(), fc.integer(), (a, b) => {
return a + b === b + a;
})
);
});
// Testing a sort function:
// Property: "The output array should have the same length as input"
// Property: "The output array should be sorted"
// fast-check will generate empty arrays, arrays with 1 item, arrays with duplicates, etc.
Instead of testing specific inputs (1 + 1 = 2), you define properties that should always hold true (e.g., for any numbers a and b, a + b = b + a). The tool generates thousands of random inputs to try and break your code.
import WS from 'jest-websocket-mock';
test('receives message', async () => {
const server = new WS('ws://localhost:1234');
const client = new WebSocket('ws://localhost:1234');
await server.connected;
client.onmessage = (e) => {
expect(e.data).toBe('hello');
};
server.send('hello');
server.close();
});
// Testing a Chat App
// 1. Connect client.
// 2. Mock server sending a "New Message" payload.
// 3. Assert that the message appears in the UI list.
You can mock the WebSocket object in the browser environment. Libraries like mock-socket allow you to simulate a server sending messages to your client and verify how the client reacts.
// Gremlins.js
import gremlins from 'gremlins.js';
gremlins.createHorde().unleash();
// Randomly clicks, scrolls, touches, types everywhere.
// Run this on a staging environment.
// Check console for errors.
// Did the app crash? Did it freeze?
// Helps find memory leaks or unhandled exceptions in obscure UI states.
Intentionally injecting failures (network latency, 500 errors, random clicks) into the frontend to see if it recovers gracefully. Tools like Gremlins.js unleash a horde of "gremlins" to click everywhere randomly.
// Functional testing is hard because Canvas is a black box to DOM.
// Strategy: Visual Regression.
test('canvas draws circle', async ({ page }) => {
await page.goto('/canvas-demo');
await expect(page.locator('canvas')).toHaveScreenshot('circle.png');
});
// Alternatively, test the logic that *drives* the canvas.
// If you have a `drawCircle(x, y, radius)` function, test that function's math
// separately from the actual rendering context.
Since Canvas is just pixels, you can't query elements like DOM. You rely heavily on Visual Regression Testing (snapshots of the canvas) or testing the internal state logic that drives the canvas rendering.
// The Trophy Shape:
// 🏆
// E2E (Top, Smallest)
// Integration (Middle, Largest)
// Unit (Bottom, Medium)
// Static (Base, Broad)
// Contrast with "Testing Pyramid" (which emphasizes Unit tests).
// Modern React apps rely heavily on composition and hooks.
// Integration tests (testing components together) give the best "Confidence per Dollar".
A metaphor prioritizing Integration Tests.
Top: E2E (Few)
Middle: Integration (Many) - "Write tests. Not too many. Mostly integration."
Bottom: Unit (Some)
Base: Static Analysis (Always)
// Playwright
test('works offline', async ({ page }) => {
await page.goto('/');
await page.context().setOffline(true);
await page.reload();
await expect(page.locator('h1')).toHaveText('My App');
});
// Verify that the Service Worker cached the assets.
// Verify that requests are served from Service Worker (check Network tab via protocol).
Best tested in E2E (Playwright/Cypress) because they rely on browser APIs not present in JSDOM. You can verify offline behavior by toggling network status in the test.
// Traditional (Waterfall):
// Design -> Code -> Test -> Deploy
// Bug found in Test phase = Expensive fix.
// Shift Left:
// Design -> Test/Code -> Deploy
// Bug found in Code phase (via Unit/Lint) = Cheap fix.
// Implementing Pre-commit hooks (Husky).
// Running tests on every file save.
// Writing tests *before* code (TDD).
// All these move testing "left" on the timeline.
Moving testing earlier in the development cycle. Instead of testing just before release, you test during development (Unit tests, PR checks). It makes fixing bugs cheaper and faster.
// Playwright: Reuse state
test.use({ storageState: 'auth.json' });
test('dashboard access', async ({ page }) => {
await page.goto('/dashboard');
// Already logged in!
});
// Global Setup:
// 1. Run a setup script that logs in via API (POST /login).
// 2. Save the returned Cookies/LocalStorage to 'auth.json'.
// 3. All tests reuse this file.
// Saves massive amounts of time vs logging in via UI for every test.
In E2E tests, don't type username/password in the UI every time (slow). Instead, make a direct API request to get a session token, set it in the browser's LocalStorage/Cookie, and reload. This is "Programmatic Login".
// Code
const showNewFeature = useFeatureFlag('new-checkout');
return showNewFeature ? <NewCheckout /> : <OldCheckout />;
// Test
test('renders new checkout when flag is on', () => {
mockFlags({ 'new-checkout': true });
render(<App />);
expect(screen.getByText('New Checkout')).toBeInTheDocument();
});
// Tools like LaunchDarkly or Split.io.
// Tests must ensure that BOTH paths work.
// Don't delete the old code/tests until the A/B test is 100% complete and the old feature is retired.
Serving different versions of a feature to different users. Developers implement this using Feature Flags. Tests should verify both paths (Flag ON and Flag OFF).
test('upload file', async () => {
const file = new File(['(content)'], 'hello.png', { type: 'image/png' });
const input = screen.getByLabelText(/upload/i);
await userEvent.upload(input, file);
expect(input.files[0]).toBe(file);
expect(input.files.item(0)).toBe(file);
});
// Testing file size validation
const hugeFile = new File([''], 'huge.png');
Object.defineProperty(hugeFile, 'size', { value: 1024 * 1024 * 10 }); // 10MB
await userEvent.upload(input, hugeFile);
expect(screen.getByText('File too large')).toBeVisible();
const file = new File(['hello'], 'hello.png', { type: 'image/png' });
const input = screen.getByLabelText(/upload/i);
await userEvent.upload(input, file);
expect(input.files[0]).toBe(file);
// A simple E2E test that runs after deployment
test('smoke test', async ({ page }) => {
await page.goto('https://production.com');
await expect(page).toHaveTitle(/My App/);
await expect(page.locator('#root')).toBeVisible();
});
// If this fails, rollback immediately.
// It doesn't check deep functionality, just "Did the server start?" and "Is the page white?".
A quick set of tests run after a build to ensure the critical functions work (e.g., "Can the app load?", "Can I log in?"). If smoke tests fail, there's no point running the full suite.
// Mock IntersectionObserver
beforeEach(() => {
window.IntersectionObserver = vi.fn((callback) => ({
observe: vi.fn(),
disconnect: vi.fn(),
// Manually trigger callback to simulate scroll
trigger: (entries) => callback(entries)
}));
});
// In test:
render(<InfiniteList />);
// Simulate element coming into view
act(() => {
const observer = window.IntersectionObserver.mock.results[0].value;
observer.trigger([{ isIntersecting: true }]);
});
expect(fetchMoreData).toHaveBeenCalled();
You need to mock the IntersectionObserver API in JSDOM. In the test, you manually trigger the observer callback to simulate the user scrolling to the bottom.
// Scenario:
// 1. Bug reported: "Login fails if password has space".
// 2. Fix bug.
// 3. Write test case: "Login with space in password".
// 4. This test is now part of the "Regression Suite".
// Every time you run your full test suite, you are doing Regression Testing.
// You are verifying that new changes didn't break old features.
Re-running functional and non-functional tests to ensure that previously developed and tested software still performs after a change. Automated suites are essentially regression suites.
// Playwright
const frame = page.frameLocator('#payment-iframe');
await frame.getByPlaceholder('Card Number').fill('4242 4242...');
// Common in Payment Gateways (Stripe elements) or embedded widgets.
// You cannot access iframe content from the main page context due to security (CORS)
// unless you explicitly switch context in the test runner.
In Playwright, you have to switch context to the frame: page.frameLocator('#my-frame').getByText('Submit'). Standard getBy selectors won't find elements inside an iframe.
// Lighthouse CI (lighthouserc.js)
module.exports = {
ci: {
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'first-contentful-paint': ['warn', { maxNumericValue: 2000 }],
},
},
},
};
// Run in CI pipeline.
// If a developer adds a 5MB image, the performance score drops.
// The build fails, preventing the regression from reaching production.
Measuring metrics like LCP (Largest Contentful Paint) and TBT (Total Blocking Time). You can run Lighthouse in your CI pipeline to fail the build if performance drops below a threshold.
const Bomb = () => { throw new Error('Boom'); };
test('catches error', () => {
// Prevent console.error from cluttering output
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Error Caught</div>}>
<Bomb />
</ErrorBoundary>
);
expect(screen.getByText('Error Caught')).toBeInTheDocument();
consoleSpy.mockRestore();
});
// Ensure your app doesn't white-screen on crash.
// Test that the "Try Again" button in the Error Boundary actually resets the state.
Create a component that throws an error (e.g., const Bomb = () => { throw new Error('Boom') }). Render it inside your Error Boundary. Assert that the fallback UI is displayed and the error was logged (you might need to suppress console.error in the test).