Learn by Directing AI
Unit 6

Testing, Security, and Performance

Step 1: Introduce React Testing Library

In P2, you wrote tests with Vitest for filtering logic and basic component rendering. React Testing Library adds something different: a philosophy about how tests should work.

The query hierarchy encodes a priority order. getByRole first -- it queries elements the way assistive technology does, which means the way users interact with components. getByLabelText for form fields. getByText for visible content. getByTestId is a last resort -- it tests the implementation (a specific DOM attribute) rather than what the user actually sees.

This matters because tests that use CSS selectors or test IDs break when the markup changes, even if the user experience stays the same. Tests that use role-based queries break only when the user experience changes -- which is exactly when you want a test to break.

The testing pyramid is a cost model. Unit tests are cheap and fast -- many of them. Component tests are slower and more brittle -- fewer of them. E2E tests are expensive and fragile -- fewest. This isn't a rule; it's economics. Understanding the costs lets you make deliberate choices about where to invest.

Step 2: Write component tests

Direct Claude to write tests for the order form:

Write component tests for the order form using React Testing Library. Test: when the customer name is empty, the submit button is disabled. When the user fills in a valid customer name and selects a product, the submit button is enabled. When the form submits successfully, the user sees a confirmation. Use getByRole and getByLabelText queries, not CSS selectors or getByTestId.

Review what Claude generates. AI commonly reaches for container.querySelector or CSS class selectors instead of role-based queries. If you see .querySelector('.submit-btn'), redirect: "Use getByRole('button', { name: /create order/i }) instead of CSS selectors."

Check what the tests are testing. A test that asserts on internal component state (expect(component.state.isValid).toBe(true)) is testing implementation. A test that asserts on what the user sees (expect(screen.getByRole('button')).toBeDisabled()) is testing behavior. When the code is refactored but the user experience stays the same, implementation tests break. Behavioral tests don't.

Step 3: Check mocking complexity

Look at the test setup. If the order form test needs to mock the API, the database connection, three context providers, and a router, that's a signal. The component might be doing too much. Extensive mocking often means the component has too many responsibilities -- it fetches data, manages form state, handles validation, and submits all in one place.

Ask Claude: "Can this component be split into a data-fetching wrapper and a presentational form? Would that reduce the mocking needed for tests?" If splitting reduces the mocking from five dependencies to one, that's a design improvement driven by test difficulty.

Step 4: Configure CSP

Content Security Policy is a whitelist of allowed content sources. The browser reads the CSP header and refuses to execute anything not explicitly permitted -- even if malicious code was injected into the page. A strict CSP prevents XSS regardless of encoding flaws.

Direct Claude to add CSP:

Add a Content Security Policy to the Next.js application. Set: default-src 'self', script-src 'self', style-src 'self', img-src 'self' data:, connect-src 'self', font-src 'self'. Do not use unsafe-inline or unsafe-eval.

Review the output. AI commonly adds unsafe-inline and unsafe-eval because they make things work without dealing with strict policy constraints. Those directives defeat the entire purpose of CSP -- they tell the browser "run any inline script and evaluate any string as code," which is exactly what XSS exploits.

Step 5: Configure CORS

CORS controls which external domains can access your API. In production, your frontend and API might run on different origins. Without CORS, the browser blocks the frontend's API requests. With CORS configured too loosely (Access-Control-Allow-Origin: *), any website can call your API.

Direct Claude to configure CORS so only the application's own origin is allowed. Review: does the CORS configuration use * or does it specify the exact origin?

Step 6: Add CSRF protection

The order creation form submits data that modifies the database. Without CSRF protection, a malicious site could embed a hidden form that submits orders using Marco's authenticated session. CSRF tokens prevent this -- the legitimate form includes a secret value that the malicious site can't access.

Direct Claude to add CSRF tokens to the order form and validate them on the API side. The token should be generated on the server, included in the form as a hidden field, and checked on submission.

Step 7: Check policy interactions

CSP, CORS, and CSRF interact. The CSP must allow the CSRF token injection method. The CORS policy must accept requests from the origin that CSRF-protected forms submit from. A strict CSP that blocks inline scripts could break CSRF token injection if the token is injected via inline JavaScript.

Check that all three policies work together. Open the order form, fill it out, and submit. If the submission fails, check the browser console -- a CSP violation or CORS error will show up there. If the token validation fails, the API will return 403.

Step 8: Profile performance

Open Chrome DevTools and go to the Performance tab. Record a page load of the inventory dashboard. The flame chart shows main-thread activity as nested function calls -- wider bars mean longer execution.

Look for long tasks. A long task is any block that takes more than 50ms -- it blocks the main thread and makes the page feel slow. The profiler marks these with a red triangle in the corner. If you see long tasks, trace them to the function that's taking too long.

Check code splitting. Open the Network tab and navigate from the inventory dashboard to the order form. A separate JavaScript chunk should load for the order form route. If all the JavaScript loaded on the first page, code splitting isn't working. AI applies code splitting syntax but doesn't always analyze which components actually benefit from lazy loading.

Step 9: Check font loading

Look at how fonts load. The choice between font-display: swap and font-display: optional is a design decision about which visual compromise is acceptable. Swap shows a flash of unstyled text (FOUT) -- the content is readable immediately but the font swaps in when loaded. Optional hides text briefly (FOIT) -- cleaner appearance but the user sees nothing until the font arrives.

Check the CSS or the Next.js font configuration. Which strategy is being used? Is it a deliberate choice?

Step 10: Review tree shaking

Tree shaking removes dead code at build time -- functions you import but never call get eliminated from the final bundle. But tree shaking only works with ES module import/export syntax. CommonJS require() pulls in entire modules because the bundler can't statically analyze what's used.

Check the imports. Are any libraries imported via CommonJS? If Claude imported a utility library with require, and you only use one function from it, the entire library ships to users. Switch to a named ES module import.

Run a production build and check the bundle sizes. If a route's JavaScript is unusually large, trace the imports.

Run the tests:

npx vitest run

All tests should pass. If any tests fail or use CSS selectors instead of role-based queries, fix them before moving on.

✓ Check

Check: Run npx vitest run -- all tests pass. Check browser DevTools: CSP header present without unsafe-inline. Order form includes a CSRF token. DevTools Performance profiler shows no long tasks >50ms on the inventory dashboard. Network tab shows separate JS chunk for order form route.