A test is useful when it detects a meaningful regression and explains the broken contract. More tests are not automatically more confidence.
Before we begin: let us learn this together
Testing becomes useful when it gives us trustworthy evidence about behavior. These are my self-notes, written as a conversation with you: we will begin every topic with the question a junior developer naturally asks, answer it in plain language, and only then write the test. We are not collecting green ticks. We are deciding which risk to examine, which boundary gives useful evidence, and why a failure deserves our attention.
1. “What should I understand about choose the boundary?”
- unit: one pure rule or transformation;
- integration: collaborators working together;
- component: user-visible behavior in a rendered component;
- API/database: transport, authorization and persistence;
- end-to-end: critical journey through the deployed-style system.
2. “Can we unpack unit-test domain behavior?”
function percentage(completed: number, total: number): number {
if (total < 0 || completed < 0 || completed > total) throw new RangeError();
return total === 0 ? 0 : Math.round((completed / total) * 100);
}
describe("percentage", () => {
it("returns rounded progress", () => {
expect(percentage(2, 3)).toBe(67);
});
it("rejects impossible progress", () => {
expect(() => percentage(4, 3)).toThrow(RangeError);
});
});
Test boundaries and invariants, not private helper calls.
3. “Why does it matter to understand arrange, act, assert?”
it("publishes a valid draft", async () => {
const repository = new FakeCourseRepository([{ id: "c1", status: "draft" }]);
const service = new CourseService(repository);
const result = await service.publish("c1");
expect(result.status).toBe("published");
});
Keep setup relevant. Builders reduce noise when objects are large.
4. “How should I think about test through accessible UI?”
Testing Library recommends interacting as users do.
test("creates a course", async () => {
const user = userEvent.setup();
render(<CreateCourse />);
await user.type(screen.getByRole("textbox", { name: "Course title" }), "JavaScript");
await user.click(screen.getByRole("button", { name: "Create course" }));
expect(await screen.findByText("Course created")).toBeVisible();
});
Role/name queries encourage accessible markup and resist refactors better than CSS classes.
5. “Can you explain mock at stable boundaries?”
Intercept HTTP instead of mocking internal hooks:
server.use(
http.post("/api/courses", async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ data: { id: "c1", ...body } }, { status: 201 });
}),
);
Mock time, randomness, email, payment and network at intentional boundaries. Too many mocks can produce a test of the mocks rather than the product.
6. “What should I understand about async testing?”
Wait for an observable outcome, not a sleep.
await user.click(saveButton);
expect(await screen.findByRole("status", { name: "Saved" })).toBeVisible();
Use fake timers only when timer behavior is the contract. Restore them after each test. Ensure rejected Promises are awaited.
7. “Can we unpack aPI and database tests?”
it("prevents a viewer updating another tenant's course", async () => {
const response = await request(app)
.patch("/api/courses/tenant-b-course")
.set("Authorization", viewerToken)
.send({ title: "Changed" });
expect(response.status).toBe(403);
});
When PostgreSQL behavior matters, test against PostgreSQL. Apply real migrations, isolate data and verify constraints/transactions.
8. “Why does it matter to understand end-to-end tests?”
test("learner completes a lesson", async ({ page }) => {
await loginAsLearner(page);
await page.goto("/courses/javascript");
await page.getByRole("link", { name: "Closures" }).click();
await page.getByRole("button", { name: "Mark complete" }).click();
await expect(page.getByText("Lesson completed")).toBeVisible();
});
Keep E2E tests focused on valuable journeys. Seed through stable APIs or fixtures. Capture traces/screenshots on failure.
9. “How should I think about security and accessibility tests?”
Test anonymous, wrong-role, wrong-tenant and stale-session cases. Run automated accessibility checks and keyboard tests, while remembering automation cannot judge every interaction or wording issue.
10. “Can you explain flakiness?”
Flakiness often comes from shared state, uncontrolled time, unordered concurrency, unstable selectors or waiting for implementation details. Do not hide it with retries before finding the cause.
11. “What should I understand about coverage and mutation?”
Coverage shows executed code, not whether assertions would detect wrong behavior. Mutation testing deliberately changes code to see whether tests fail. Use metrics as diagnostic tools, not targets detached from risk.
12. “Can we unpack cI test strategy?”
Run fast deterministic checks on every change. Parallelise isolated suites. Cache dependencies carefully. Keep test artifacts. Run production-like E2E smoke tests against the built artifact.
13. “Why does it matter to understand practice project?”
Test StudyDesk at five levels: pure progress rule, React/Angular form, HTTP contract, real database authorization and one E2E learning journey. Force validation, conflict, network and server errors. For each test, write the business risk it protects.
Mastery statement: test observable contracts at appropriate boundaries, control nondeterminism and make failures provide evidence rather than noise.
14. “How should I think about testing as a fire-alarm system?”
A test suite is not a trophy cabinet. It is a warning system that gives evidence when behaviour changes. A smoke alarm near the kitchen detects a different risk from a building-wide evacuation drill; unit and end-to-end tests likewise protect different boundaries.
Write the risk before writing the test: “A learner must not lose progress when saving twice.” This keeps tests attached to value rather than implementation trivia.
15. “Can you explain arrange, Act, Assert?”
Most examples become clearer when divided into three phases:
it("caps lesson progress at 100 percent", () => {
// Arrange
const lesson = { completedSteps: 9, totalSteps: 10 };
// Act
const result = completeStep(lesson, 5);
// Assert
expect(result.percentage).toBe(100);
});
One test may contain several assertions about one behaviour. Avoid forcing every assertion into a separate test if that repeats expensive setup without improving diagnosis.
16. “What should I understand about test behaviour, not plumbing?”
This test is fragile:
expect(component.state.isOpen).toBe(true);
The user does not care about an internal variable. Prefer an observable result:
await user.click(screen.getByRole("button", { name: /course details/i }));
expect(screen.getByRole("dialog", { name: /course details/i })).toBeVisible();
Now the component can change from local state to a reducer without rewriting the test. Queries by role and name also encourage accessible markup.
17. “Can we unpack the testing portfolio?”
There is no universal perfect pyramid. Build a portfolio based on risk:
- many fast tests for calculations and rules;
- integration/component tests for collaboration between units;
- API tests for transport, authorization and persistence;
- a smaller number of end-to-end tests for critical journeys;
- static analysis for type and lint rules.
18. “Why does it matter to understand unit-testing pure rules?”
Pure functions are easy to exercise because inputs determine outputs:
type Discount = { kind: "student" | "coupon"; amount: number };
export function finalPrice(price: number, discounts: Discount[]): number {
const reduction = discounts.reduce((sum, item) => sum + item.amount, 0);
return Math.max(0, price - reduction);
}
it.each([
{ price: 50, discounts: [], expected: 50 },
{ price: 50, discounts: [{ kind: "coupon", amount: 10 }], expected: 40 },
{ price: 5, discounts: [{ kind: "student", amount: 20 }], expected: 0 },
])("returns $expected", ({ price, discounts, expected }) => {
expect(finalPrice(price, discounts)).toBe(expected);
});
Table-driven tests show the rule's dimensions. Include boundaries: zero, exact limit, one below and one above.
19. “How should I think about test doubles: fake colleagues?”
A stub returns prepared answers. A spy records calls. A mock commonly combines expectations with simulated behaviour. A fake is a lightweight working implementation, such as an in-memory repository.
class InMemoryCourseRepository implements CourseRepository {
courses = new Map<string, Course>();
async save(course: Course) { this.courses.set(course.id, course); }
async find(id: string) { return this.courses.get(id) ?? null; }
}
Use doubles at true boundaries—email, clock, payment gateway—not for every internal function. Excessive mocking creates tests that confirm your wiring diagram while real behaviour remains broken.
20. “Can you explain control time explicitly?”
Time is an input even when hidden behind new Date().
type Clock = { now(): Date };
function isExpired(expiresAt: Date, clock: Clock): boolean {
return expiresAt.getTime() <= clock.now().getTime();
}
const fixedClock = { now: () => new Date("2030-01-01T12:00:00Z") };
expect(isExpired(new Date("2030-01-01T11:59:59Z"), fixedClock)).toBe(true);
Dependency injection often gives clearer tests than globally replacing timers. Use fake timers when scheduling itself is under test.
21. “What should I understand about component tests as small user stories?”
it("explains why an empty title cannot be saved", async () => {
const user = userEvent.setup();
render(<CourseForm onSave={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /create course/i }));
expect(screen.getByLabelText(/course title/i)).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/enter a course title/i)).toBeVisible();
});
Use userEvent for realistic interactions. Await asynchronous actions. Do not query CSS classes unless class output is the contract.
22. “Can we unpack network tests with a boundary simulator?”
Instead of mocking fetch differently in every component, simulate HTTP at the network boundary with a tool such as Mock Service Worker:
const server = setupServer(
http.get("/api/courses", () => HttpResponse.json([
{ id: "js", title: "JavaScript Foundations" },
])),
);
Then render the real data-fetching code and verify loading, success, empty and error states. Keep a test that fails on unexpected requests so missing handlers are visible.
23. “Why does it matter to understand aPI contract tests?”
An API test should exercise the public HTTP contract:
it("returns a machine-readable validation problem", async () => {
const response = await request(app)
.post("/api/courses")
.set("Authorization", instructorToken)
.send({ title: "" });
expect(response.status).toBe(422);
expect(response.body).toMatchObject({
type: "validation-error",
errors: { title: expect.any(Array) },
});
});
Include media type, status, headers and body shape. Consumer-driven contract tests can help independently deployed clients and services agree, but they do not replace end-to-end behaviour checks.
24. “How should I think about database tests and transactions?”
Mocks cannot reproduce PostgreSQL constraints, isolation or query semantics. When those matter, use the real engine in an isolated test environment.
it("rolls back enrollment when payment recording fails", async () => {
await expect(enrolWithBrokenPayment(userId, courseId)).rejects.toThrow();
expect(await enrollmentRepository.find(userId, courseId)).toBeNull();
});
Apply production migrations. Give each test isolated data through rollback, schemas or unique identifiers. Parallel tests must not share mutable records accidentally.
25. “Can you explain end-to-end journeys?”
Choose a handful of journeys whose failure would seriously hurt users or the business: sign in, buy, publish, complete, recover.
test("instructor publishes a valid course", async ({ page }) => {
await page.goto("/courses/new");
await page.getByLabel("Course title").fill("Gentle TypeScript");
await page.getByRole("button", { name: "Create course" }).click();
await expect(page.getByRole("heading", { name: "Gentle TypeScript" })).toBeVisible();
});
Prefer resilient accessible locators. Avoid fixed sleeps; wait for a meaningful state. Capture trace, screenshot, console and network evidence on failure.
26. “What should I understand about property-based testing?”
Example tests cover chosen points. Property-based tests generate many inputs and check an invariant.
fc.assert(fc.property(fc.array(fc.integer({ min: 0, max: 100 })), scores => {
const average = calculateAverage(scores);
return scores.length === 0 || (average >= 0 && average <= 100);
}));
Useful properties include round trips, sorting order, totals never becoming negative and parsers never crashing on arbitrary input. When a failure appears, good tools shrink it to a small counterexample.
27. “Can we unpack mutation testing?”
A mutation tool changes > to >=, removes a condition or changes a return value. If tests still pass, the mutant survived and exposes a weak assertion or untested branch.
Do not chase a perfect mutation score. Use survivors to inspect important domain logic. Generated code and trivial logging rarely deserve the same attention as authorization or billing rules.
28. “Why does it matter to understand accessibility, visual and performance tests?”
Automated accessibility rules catch missing names and invalid relationships, while keyboard and assistive-technology checks catch interaction problems. Visual regression can detect unintended spacing or colour changes, but stabilize fonts, animations and data first.
Performance assertions should measure budgets in a controlled environment:
expect(bundleReport.initialJavaScriptBytes).toBeLessThan(250_000);
Avoid precise millisecond assertions on noisy shared CI machines. Trend representative metrics and investigate meaningful regressions.
29. “How should I think about security tests are abuse stories?”
For each happy path, ask how it could be misused:
- request another tenant's record;
- replay the same payment command;
- submit extra privileged fields;
- exceed limits;
- upload a misleading file;
- use expired or revoked credentials.
30. “Can you explain diagnosing flaky tests?”
Treat a flaky test as a defect in the test or product. Classify its nondeterminism:
- Time: clocks, animation, expiry.
- Order: shared state or test pollution.
- Concurrency: races and unfinished promises.
- Environment: ports, locale, fonts, network.
- Selector: DOM implementation details.
31. “What should I understand about coverage interpreted carefully?”
Line coverage answers “was this line executed?” It does not answer “would the test detect an incorrect result?” A test can execute every branch with no meaningful assertion.
Use coverage to discover surprising gaps. Prioritize complicated, frequently changed and high-impact behaviour. A well-tested small authorization function matters more than superficial coverage of generated accessors.
32. “Can we unpack cI pipeline design?”
A practical pipeline progresses from fast feedback to broader confidence:
steps:
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run test:unit -- --run
- run: npm run build
- run: npm run test:integration
- run: npm run test:e2e
Parallelize suites only when isolation is sound. Test the deployable artifact, retain reports, and make failed jobs easy to reproduce locally. Quarantine should be short-lived, assigned and visible.
33. “Why does it matter to understand a mentoring code-review conversation?”
“Should I mock the course repository?” First ask what you need to learn. If testing a discount rule, pass simple values. If testing a service's reaction to a repository failure, a fake or stub helps. If verifying a SQL constraint, use PostgreSQL. If proving the learner can finish a lesson, exercise the running application.
The boundary follows the question. Choosing the test level before stating the risk reverses the reasoning.
34. “How should I think about seven-day revision plan?”
- Day 1: write pure unit tests with boundaries and tables.
- Day 2: practise fakes, stubs, spies and explicit clocks.
- Day 3: test a component through roles, names and user actions.
- Day 4: test network success, empty, validation and failure states.
- Day 5: run real database and authorization integration tests.
- Day 6: automate one valuable end-to-end journey and inspect its trace.
- Day 7: diagnose a deliberately flaky test and review coverage/mutants.
35. “Faz, what should I test first when a feature is still new?”
Start with the promise most capable of hurting the user if it breaks. Do not start with the easiest function merely because it produces a quick green tick.
For a course-enrollment feature, I would write the promise in ordinary language: “A learner who pays once receives one active enrollment, and a repeated delivery cannot create another charge or enrollment.” That sentence reveals several test boundaries. A pure rule can decide eligibility. An integration test can prove the uniqueness constraint and transaction. An API test can prove authentication and idempotency behavior. One end-to-end journey can show the user reaches the course.
Notice what we did not do: count components and create one test file for each. The behavior selected the tests.
it("does not create a second enrollment for a repeated operation", async () => {
const command = { learnerId, courseId, idempotencyKey: "enrol-42" };
const first = await enrollmentService.enrol(command);
const second = await enrollmentService.enrol(command);
expect(second.id).toBe(first.id);
expect(await enrollmentRepository.countFor(learnerId, courseId)).toBe(1);
});
Ask what this test still does not prove. If the repository is in memory, it does not prove the database constraint. We need another test at that boundary.
36. “How do I decide between unit, integration and end-to-end?”
Do not choose from ideology. Choose from the question.
If you ask, “Does this discount rule return the correct total for boundary values?” a unit test gives precise, fast evidence. If you ask, “Will the transaction roll back in PostgreSQL?” use PostgreSQL. If you ask, “Can a keyboard user buy the course through the deployed application?” use an end-to-end browser test.
Think of examining a bicycle. Testing the brake cable alone helps locate a cable defect. Testing the assembled brake catches collaboration problems. Riding the bicycle down a safe test track checks the experience. One level cannot replace every other level.
The smallest trustworthy boundary is usually a good default. “Smallest” keeps diagnosis focused; “trustworthy” prevents us from mocking away the behavior we care about.
37. “Why can too much mocking make me less confident?”
A mock is a story we tell the test about a collaborator. If our story is wrong, the test confidently proves a fictional system.
Suppose production's payment client returns { status: "succeeded" }, but our mock returns { success: true }. The service and mock can evolve together while real integration fails. Contract fixtures or a provider sandbox are better evidence for that boundary.
Mock volatile external effects when the test is about your reaction:
const payments = {
charge: vi.fn().mockRejectedValue(new PaymentUnavailableError()),
};
await expect(checkout({ payments })).rejects.toThrow(PaymentUnavailableError);
expect(enrollments.find(learnerId, courseId)).resolves.toBeNull();
Then keep separate integration/contract tests proving the adapter understands the real provider. Mocks are useful scalpels, not building materials for a replica of the whole application.
38. “How should I test asynchronous UI without sleeping?”
Wait for the observable state the user cares about. A fixed sleep guesses how long the machine needs and makes fast runs slow while slow runs flaky.
render(<CourseList />);
expect(screen.getByRole("status", { name: /loading courses/i })).toBeVisible();
expect(await screen.findByRole("heading", { name: "JavaScript Foundations" }))
.toBeVisible();
If the feature saves after a click:
await user.click(screen.getByRole("button", { name: "Save course" }));
await screen.findByText("Course saved");
The test waits until success or its own diagnostic timeout. If no stable UI state exists, that may be a product accessibility problem as well as a testing problem.
Always await user actions and rejected Promises. An unawaited assertion can finish after the test has already reported success.
39. “When are fake timers the right tool?”
Use fake timers when scheduled time is the behavior: debounce, expiry, retry delay or interval cleanup. If time merely supplies “now,” inject a clock instead.
it("autosaves after the learner pauses for 800 ms", async () => {
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const save = vi.fn();
render(<NotesEditor onSave={save} />);
await user.type(screen.getByLabelText("Notes"), "Closure notes");
expect(save).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(800);
expect(save).toHaveBeenCalledWith("Closure notes");
vi.useRealTimers();
});
Restore timers in cleanup even after failure. Remember that fake timer systems interact with microtasks and libraries; advance them through supported async helpers rather than mixing real waits.
40. “How do I test a form as a conversation with the user?”
Test the journey, not the state object. Begin empty, submit, observe explanation, correct one field and submit again.
it("helps a learner correct an invalid email", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<EnrollmentForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email address"), "not-an-email");
await user.click(screen.getByRole("button", { name: "Enroll" }));
const email = screen.getByLabelText("Email address");
expect(email).toHaveAttribute("aria-invalid", "true");
expect(screen.getByText(/enter an address such as/i)).toBeVisible();
expect(onSubmit).not.toHaveBeenCalled();
await user.clear(email);
await user.type(email, "learner@example.com");
await user.click(screen.getByRole("button", { name: "Enroll" }));
expect(onSubmit).toHaveBeenCalledOnce();
});
This test protects validation, accessible association and submission behavior together.
41. “What belongs in an API integration test?”
Treat the HTTP message as the public contract. Assert method outcome, status, important headers, media type and schema. Include negative identities and malformed inputs.
it("conceals another tenant's private course", async () => {
const response = await request(app)
.get(`/api/courses/${otherTenantCourseId}`)
.set("Authorization", tenantAToken);
expect(response.status).toBe(404);
expect(response.headers["content-type"]).toMatch(/application\/problem\+json/);
});
Why 404 rather than 403? This application's policy conceals existence. Your policy may differ, but the test makes the decision stable.
Test cache validators, idempotency keys, pagination cursors, rate-limit responses and content types when those are promises. Do not inspect private controller calls from an HTTP test; that couples it to implementation.
42. “How can database tests stay isolated?”
Use the same database engine when engine behavior matters. Apply real migrations. Then isolate tests with transactions, schemas, databases or unique identifiers according to parallelism and connection behavior.
A transaction rolled back after each test is fast, but application code that opens separate connections may not see that transaction. Per-worker schemas offer stronger parallel isolation at setup cost. Containers provide realism at greater startup cost.
beforeAll(async () => migrate(testDatabase));
beforeEach(async () => seedMinimumReferenceData(testDatabase));
afterEach(async () => cleanOwnedRows(testRunId));
Do not share “the default user” across parallel tests. Give records a test-run owner and make cleanup precise. A flaky unique-constraint test often exposes data ownership confusion in the suite.
43. “What makes an end-to-end test valuable rather than expensive noise?”
Choose a journey crossing boundaries whose failure matters: sign in, purchase, publish, recover, complete. Keep the path human-readable and the setup controlled.
test("learner resumes and completes a lesson", async ({ page }) => {
await seedLearnerWithProgress({ lesson: "closures", percent: 60 });
await signIn(page, learner);
await page.goto("/courses/javascript/closures");
await expect(page.getByText("60% complete")).toBeVisible();
await page.getByRole("button", { name: "Mark lesson complete" }).click();
await expect(page.getByText("Lesson completed")).toBeVisible();
});
Seed through stable APIs or database fixtures rather than clicking through unrelated setup. Use accessible locators. Capture traces, network and screenshots on failure. Avoid one enormous test that prevents every later assertion after its first failure.
44. “How do I diagnose a test that only fails in CI?”
First gather evidence before rerunning until green. Compare runtime, browser, timezone, locale, CPU, test order, parallelism and external dependencies. Preserve the trace and random seed.
Run the suspect repeatedly and in isolation, then before/after neighboring tests. Freeze time or randomness where it belongs. Search for shared ports, files, database rows and singleton mocks. Replace sleep with condition-based waiting.
Quarantine can protect the signal briefly, but attach an owner and deadline. A permanently quarantined critical test is not protection.
45. “What does good coverage look like?”
Coverage is a map of execution, not a certificate of correctness. Use it to ask why important branches were never exercised.
A line can be covered by a test with no meaningful assertion. Conversely, generated code and defensive logging can lower a percentage without increasing risk. Set thresholds only as guardrails and review changes in context.
I prefer a risk inventory: authorization rules, money movement, data migrations, recovery paths and concurrency invariants deserve explicit tests. Then coverage reports help find forgotten paths inside that inventory.
46. “Can tests improve the design before they run?”
Yes. If a pricing rule cannot be tested without booting a browser, database and network, the rule may be tangled with infrastructure. Extracting a pure domain function can clarify ownership.
But do not split every five lines into an interface solely for mocking. Testability is about observable boundaries and controlled inputs, not maximum indirection.
Ask: can I supply time, randomness and external effects deliberately? Can I observe the business outcome without reading private state? Can failures be reproduced? Those questions often improve production architecture.
47. “How should the CI pipeline use this portfolio?”
Give fast deterministic evidence first, broader evidence later:
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run test:unit -- --run
- run: npm run build
- run: npm run test:integration
- run: npm run test:e2e
Test the built artifact. Parallelize only isolated work. Retain reports and traces. Do not let a flaky suite train developers to ignore red builds.
Production smoke tests should be small and non-destructive. Monitoring then continues testing assumptions through real behavior, but it does not replace pre-release checks.
48. “What should I remember when I close these testing notes?”
Remember this conversation:
Junior: “How many tests are enough?”
Faz: “Enough to give trustworthy evidence about the risks we have chosen to protect. Count confidence and diagnostic value before counting files.”
Junior: “Which test level is best?”
Faz: “The smallest boundary that can still tell the truth about your question.”
Junior: “What makes a test maintainable?”
Faz: “It describes observable behavior, controls nondeterminism, owns its data and leaves useful evidence when it fails.”
That is the heart of testing. We are not proving that change is impossible. We are building a sensitive, trustworthy warning system so change can remain safe.
