Learn Frontend Part 15: Tooling, TypeScript and Testing - Vite, ESLint, React Testing Library and Playwright

Learn Frontend Part 15: Tooling, TypeScript and Testing - CodingHumans
Learn Frontend - Part 15 of 15 | Full roadmap← Web Security

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.

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.2 allows any 1.x.x from 1.4.2 up; ~1.4.2 allows only 1.4.x patches; 1.4.2 pins exactly. A major bump means breaking changes.
  • The lock file (package-lock.json, pnpm-lock.yaml or yarn.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 ci in 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 import from 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; CommonJS require cannot 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: any turns type checking off. unknown accepts 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, ReturnType and Awaited derive new types from existing ones.
  • Turn on "strict": true in tsconfig.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.ReactNode for anything renderable, such as children.
  • 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:

TypeWhat it checksToolsSpeed
StaticTypes and lint rulesTypeScript, ESLintInstant
UnitA pure function or hook in isolationVitest, JestVery fast
Integration / componentComponents working together as a user sees themReact Testing Library, MSWFast
End-to-endReal browser, real app, full user flowsPlaywright, CypressSlow

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, then getByLabelText, getByText, and getByTestId only as a last resort. Role queries also catch accessibility problems.
  • getBy throws if not found, queryBy returns null (useful to assert absence), and findBy waits for async elements.
  • Use userEvent rather than fireEvent; 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.

Key takeaways
  • 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.

  1. Create a React + TypeScript app with Vite, turn on strict mode and fix every type error.
  2. Add ESLint, Prettier, Husky and lint-staged so a commit fails if lint or type checks fail.
  3. Model an API response as a discriminated union and write a component that handles every state.
  4. Write React Testing Library tests for a login form: successful submit, validation error and disabled button while submitting.
  5. Mock a user list API with MSW and test the loading, success and error states.
  6. Write one Playwright test for a full "add to cart and checkout" flow and run it in a GitHub Actions workflow.
  7. 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.

Learn Frontend - Part 15 of 15 | Full roadmap← Web Security

Comments

Answered 0/0Correct 0