
Writing components is only half of professional frontend work. The other half is the toolchain around them: package management, bundlers, linting, TypeScript, tests and CI. Interviewers use these topics to separate people who have shipped production code from people who have only followed tutorials, with questions like "what does a bundler actually do?", "interface or type?" and "how do you test a component that fetches data?"
This is Part 15, the final part of the Learn Frontend series, after Web Security. We cover npm and lock files, bundlers and Vite, linting and formatting, TypeScript essentials, typing React, the testing pyramid, unit and component tests, mocking APIs, end-to-end tests and CI.
- Packages, semver and lock files
- Bundlers, transpilers and Vite
- Linting, formatting and git hooks
- TypeScript essentials
- Typing React components
- The testing pyramid (or trophy)
- Component tests with React Testing Library
- Mocking APIs and end-to-end tests
- CI/CD and quality gates
- Practice problems
- Interview questions and FAQ
1. Packages, semver and lock files
Every modern frontend starts with a package.json, which lists scripts and dependencies. A few details come up often:
- dependencies are needed at runtime; devDependencies are only for building and testing (TypeScript, ESLint, Vite, Vitest). peerDependencies say "the app must provide this", which is how libraries declare React.
- Semantic versioning is
MAJOR.MINOR.PATCH.^1.4.2allows any 1.x.x from 1.4.2 up;~1.4.2allows only 1.4.x patches;1.4.2pins exactly. A major bump means breaking changes. - The lock file (
package-lock.json,pnpm-lock.yamloryarn.lock) records the exact version of every package in the tree. Commit it, so every developer and CI build installs the same versions. - Use
npm ciin CI: it installs exactly what the lock file says and fails if it is out of sync with package.json. - pnpm is popular for speed and disk use, and for monorepos with workspaces (often with Turborepo or Nx).
2. Bundlers, transpilers and Vite
Browsers understand JavaScript, CSS and HTML, but we write TypeScript, JSX, CSS modules and import hundreds of npm packages. Build tools bridge that gap:
- Transpiling: Babel, SWC or esbuild convert TypeScript and JSX into plain JavaScript, and modern syntax into syntax older browsers support.
- Bundling: follow every
importfrom the entry file, build a dependency graph and output a few optimised files instead of thousands of modules. - Tree shaking: remove exports that are never imported. It needs ES modules (
import/export), because their structure can be analysed statically; CommonJSrequirecannot be shaken reliably. - Code splitting: create separate chunks loaded on demand with dynamic
import(), for example per route (see Part 9). - Minification and hashing: shrink code and add content hashes to file names (
app.3f9a1c.js) so they can be cached forever and busted when they change.
Vite is now the default choice for new React apps outside frameworks (Create React App is deprecated). In development it serves native ES modules and only transforms files the browser requests, so startup is near instant and Hot Module Replacement is fast. For production it bundles with an optimised bundler. webpack is still common in older projects; Next.js uses its own Rust-based Turbopack.
3. Linting, formatting and git hooks
- ESLint finds bugs and bad patterns: unused variables, missing hook dependencies, accessibility problems (
eslint-plugin-jsx-a11y), and floating promises with typed rules. - Prettier formats code automatically, so reviews focus on logic, not spaces and quotes. Let Prettier own formatting and ESLint own correctness.
- Husky plus lint-staged run lint, format and type checks on staged files before each commit.
- Conventional commits (
feat:,fix:) make changelogs and versioning automatic.
4. TypeScript essentials
TypeScript adds static types to JavaScript. Types are checked at compile time and then erased; nothing exists at runtime. That is why you still validate external data (API responses, form input) at runtime with a library like Zod.
// Inference: no annotation needed
let count = 0; // number
interface User {
id: number;
name: string;
email?: string; // optional
readonly createdAt: Date;
}
type Status = "idle" | "loading" | "success" | "error"; // union of literals
// Discriminated union: the "type" field narrows the rest
type ApiResult<T> =
| { type: "success"; data: T }
| { type: "error"; message: string };
function render(result: ApiResult<User[]>) {
if (result.type === "success") {
return result.data.map(u => u.name); // data is available here
}
return result.message; // message is available here
}
// Generics
function first<T>(items: T[]): T | undefined {
return items[0];
}
// Utility types
type UserPreview = Pick<User, "id" | "name">;
type UserUpdate = Partial<Omit<User, "id" | "createdAt">>;
type UsersById = Record<number, User>;
// unknown forces narrowing
function parse(value: unknown) {
if (typeof value === "string") return value.trim();
throw new Error("Expected a string");
}- Type inference: let TypeScript infer local variables; annotate function parameters, return types of public functions and exported APIs.
- interface vs type: both describe object shapes. Interfaces can be extended and merged by redeclaring them; type aliases can also express unions, intersections, tuples and mapped types. A common convention is interfaces for object shapes and types for everything else. Either is fine if the team is consistent.
- unknown vs any:
anyturns type checking off.unknownaccepts anything but forces you to narrow before use, so prefer it for untrusted values. - Narrowing:
typeof,instanceof,in, equality checks and discriminated unions let TypeScript know the exact type in each branch. - Generics make functions and components reusable while keeping type information, like
useState<User | null>. - Utility types:
Partial,Required,Pick,Omit,Record,ReturnTypeandAwaitedderive new types from existing ones. - Turn on
"strict": trueintsconfig.json. Most of TypeScript's value comes from strict null checks.
5. Typing React components
import { useState, type ReactNode, type ComponentProps } from "react";
interface CardProps {
title: string;
children: ReactNode;
variant?: "default" | "outlined";
}
function Card({ title, children, variant = "default" }: CardProps) {
return <section className={variant}><h2>{title}</h2>{children}</section>;
}
// Accept every native button prop, plus our own
type ButtonProps = ComponentProps<"button"> & { loading?: boolean };
function Button({ loading = false, children, ...rest }: ButtonProps) {
return <button disabled={loading || rest.disabled} {...rest}>{loading ? "Saving..." : children}</button>;
}
function SearchForm({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSearch(query);
};
return <form onSubmit={handleSubmit}><input value={query} onChange={handleChange} /></form>;
}- Type props with an interface or type and destructure them in the parameter list. Plain functions are preferred over
React.FC. - Use
React.ReactNodefor anything renderable, such aschildren. - Extend native element props with
React.ComponentProps<"button">so a custom Button accepts every normal button attribute. - Event types:
React.ChangeEvent<HTMLInputElement>,React.FormEvent<HTMLFormElement>,React.MouseEvent<HTMLButtonElement>. - Refs:
useRef<HTMLInputElement>(null). State that starts empty:useState<User | null>(null).
6. The testing pyramid (or trophy)
Different tests give different confidence for different costs:
| Type | What it checks | Tools | Speed |
|---|---|---|---|
| Static | Types and lint rules | TypeScript, ESLint | Instant |
| Unit | A pure function or hook in isolation | Vitest, Jest | Very fast |
| Integration / component | Components working together as a user sees them | React Testing Library, MSW | Fast |
| End-to-end | Real browser, real app, full user flows | Playwright, Cypress | Slow |
The classic pyramid says most tests should be unit tests. For frontend, many teams follow the testing trophy instead: rely on static analysis, write mostly integration-style component tests, and keep a small number of E2E tests for critical flows such as sign-up and checkout. Aim for confidence, not a coverage percentage.
7. Component tests with React Testing Library
React Testing Library tests components the way users use them: find elements by role, label or text, interact with them, and assert on what appears. Avoid testing implementation details like internal state or class names, so refactors do not break tests.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import LoginForm from "./LoginForm";
describe("LoginForm", () => {
it("submits the email and password", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/email/i), "asha@example.com");
await user.type(screen.getByLabelText(/password/i), "secret123");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(onSubmit).toHaveBeenCalledWith({ email: "asha@example.com", password: "secret123" });
});
it("shows an error for an invalid email", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(screen.getByRole("alert")).toHaveTextContent(/valid email/i);
});
});- Query priority:
getByRole(with a name) first, thengetByLabelText,getByText, andgetByTestIdonly as a last resort. Role queries also catch accessibility problems. getBythrows if not found,queryByreturns null (useful to assert absence), andfindBywaits for async elements.- Use
userEventrather thanfireEvent; it simulates real typing and clicking, including focus and keyboard events. - Vitest runs these tests with a Jest-compatible API and is the natural choice for Vite projects; Jest is still widespread.
8. Mocking APIs and end-to-end tests
Mock Service Worker (MSW) intercepts network requests at the network level and returns fake responses. Components keep using real fetch calls, so tests stay realistic and the same mocks work in tests, Storybook and local development.
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/users", () =>
HttpResponse.json([{ id: 1, name: "Asha" }, { id: 2, name: "Ravi" }])
),
];
// UserList.test.tsx
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it("renders users from the API", async () => {
render(<UserList />);
expect(await screen.findByText("Asha")).toBeInTheDocument(); // findBy waits
});
it("shows an error when the API fails", async () => {
server.use(http.get("/api/users", () => new HttpResponse(null, { status: 500 })));
render(<UserList />);
expect(await screen.findByRole("alert")).toBeInTheDocument();
});
// e2e/checkout.spec.ts (Playwright)
import { test, expect } from "@playwright/test";
test("user can add an item and check out", async ({ page }) => {
await page.goto("/products");
await page.getByRole("button", { name: "Add to cart" }).first().click();
await page.getByRole("link", { name: "Cart" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
});End-to-end tests drive a real browser through the whole app. Playwright supports Chromium, Firefox and WebKit, auto-waits for elements and can run tests in parallel. Keep E2E tests few and focused on business-critical paths, use stable selectors (roles and labels), and avoid fixed sleeps, which cause flaky tests.
9. CI/CD and quality gates
A typical pipeline on every pull request: install with npm ci, then lint, type-check (tsc --noEmit), run unit and component tests, build, and run E2E tests against a preview deployment. Merge only when everything is green. Add bundle size and Lighthouse checks to catch performance regressions, and deploy preview URLs (Vercel, Netlify) so reviewers can click through changes. Tools like Storybook with visual regression testing catch unintended UI changes.
- Commit the lock file and use npm ci in CI so every install is identical. Know what ^ and ~ mean in semver.
- Bundlers transpile, build a dependency graph, tree-shake unused ES module exports, split code and hash file names for caching.
- Vite serves native ES modules in development for instant startup and bundles for production.
- ESLint catches bugs, Prettier formats; run both automatically with git hooks.
- TypeScript types are erased at runtime, so validate external data with something like Zod. Use strict mode, unknown over any, and discriminated unions.
- Type React props with interfaces, children with ReactNode, and extend native props with ComponentProps.
- Write mostly component tests with React Testing Library, query by role, mock APIs with MSW, and keep a few Playwright E2E tests for critical flows.
Practice problems
Set up a small project from scratch and add each layer yourself.
- Create a React + TypeScript app with Vite, turn on strict mode and fix every type error.
- Add ESLint, Prettier, Husky and lint-staged so a commit fails if lint or type checks fail.
- Model an API response as a discriminated union and write a component that handles every state.
- Write React Testing Library tests for a login form: successful submit, validation error and disabled button while submitting.
- Mock a user list API with MSW and test the loading, success and error states.
- Write one Playwright test for a full "add to cart and checkout" flow and run it in a GitHub Actions workflow.
- Analyse your production bundle with a bundle visualiser and remove one large unused dependency.
Interview questions and FAQ
What is the difference between interface and type in TypeScript?
Both can describe object shapes. Interfaces can be extended and merged by declaring them again, which is useful for public APIs. Type aliases can also describe unions, intersections, tuples and mapped types. Many teams use interfaces for objects and types for everything else.
What is tree shaking?
Removing code that is exported but never imported from the final bundle. It relies on the static structure of ES modules, so libraries published as ES modules without side effects shake best.
Why is Vite faster than webpack in development?
Vite does not bundle the whole app before starting. It serves source files as native ES modules and transforms each one only when the browser requests it, and pre-bundles dependencies with esbuild. Startup and hot updates stay fast as the app grows.
What is the difference between unknown and any?
any disables type checking for that value. unknown can hold any value but must be narrowed with checks like typeof before you use it, so it is the safe choice for untrusted data.
What should you test in a React component?
Behaviour the user can see: what renders, what happens after clicks and typing, loading and error states, and accessibility roles. Avoid testing internal state, private functions or exact markup, which makes tests break on harmless refactors.
What is the difference between getBy, queryBy and findBy?
getBy throws when the element is missing, queryBy returns null (use it to assert that something is not there), and findBy returns a promise that waits for the element to appear, for async UI.
Comments
Post a Comment