Skip to content

Testing Tdd

50 companion flashcards · AI-assisted study content · Open the deck →

This deck introduces the core ideas behind software testing and Test-Driven Development, making it a great starting point for anyone who has heard the terms but isn't sure how they fit together. You'll work through definitions of unit, integration, and end-to-end tests, get familiar with the Test Pyramid, and unpack what TDD actually looks like in practice through the Red-Green-Refactor cycle.

Beyond the basics, the deck also covers the vocabulary you'll run into in real codebases, including mocks, stubs, and spies, as well as the broader family of test doubles. You'll also get a gentle introduction to Behavior-Driven Development, Gherkin syntax, and the different ways to measure test coverage, from line and branch coverage up to path coverage.

The cards are well suited to developers new to testing practices, QA engineers building a stronger conceptual foundation, or students preparing for technical interviews. Because the content is terminology-heavy, try to answer each card in your own words before flipping it over, and revisit the deck across several short sessions rather than cramming it all at once. Spacing your reviews out will help the distinctions, like the difference between a mock and a stub or between branch and path coverage, really stick.

Test Types and Strategy

Software testing is organized around three principal levels of scope, each targeting different parts of the system. A unit test verifies the behavior of a single, isolated piece of code such as a function or method. It runs in milliseconds, uses mocked or stubbed dependencies, and is deterministic and repeatable. An integration test, by contrast, checks that multiple components work correctly together, exercising real interactions between modules, services, or layers. This may involve real databases, APIs, or file systems, which makes such tests slower than unit tests but more effective at catching interface bugs. End-to-end, or E2E, tests simulate real user workflows across the entire application stack, validating the system from the user's perspective using tools like Playwright, Cypress, or Selenium. They are the slowest and most brittle test type, but they catch integration issues that span the whole system.

The relative number of these tests is captured by the Test Pyramid, a strategy popularized by Mike Cohn. The pyramid recommends a large base of unit tests because they are fast, cheap, and numerous; a middle layer of integration tests with moderate speed and count; and a thin top layer of E2E tests that are slow, expensive, and few. The idea is to have many small, fast tests providing rapid feedback while still covering the full stack with the slower tests.

Tests can also be classified by how much they know about the implementation. Black-box testing validates functionality without reference to the internal code structure, relying instead on requirements and specifications, where the tester sees only inputs and outputs. White-box testing, in contrast, uses knowledge of the source code to design tests around code paths, branches, and logic. Unit tests are typically white-box, while E2E tests are commonly black-box, and both perspectives are needed for comprehensive coverage. Finally, acceptance testing verifies that the system meets business requirements and is ready for delivery, with user acceptance testing validating real-world scenarios and business acceptance testing confirming business rules, often automated through BDD tools.

TDD and BDD

Test-Driven Development, or TDD, is a development practice in which tests are written before production code. The cycle begins with Red, writing a failing test that describes the desired behavior. The next step, Green, involves writing the minimum code necessary to make the test pass. The cycle concludes with Refactor, where the developer cleans up the code while keeping all tests green. A core rule is never to write production code without a failing test first, and each iteration is meant to be small, on the order of minutes rather than hours.

This Red-Green-Refactor rhythm produces several benefits. Because developers must specify behavior before implementation, TDD forces them to think about the API before diving into the code. Untestable code is often a signal of poor design, such as tight coupling or hidden dependencies, and so writing tests first naturally encourages SOLID principles, dependency injection, and small functions. The result is modular, loosely coupled code that is easier to maintain, extend, and understand. As a side effect, the test suite becomes living documentation, gives confidence when refactoring, and reduces the introduction of bugs.

Behavior-Driven Development, or BDD, extends TDD by expressing tests in natural language that describes behavior from a user's perspective. BDD uses an ubiquitous language shared by developers, QA, and stakeholders, and scenarios are typically structured as Given-When-Then. Tools such as Cucumber, SpecFlow, and Behave execute these human-readable specifications. The scenarios themselves are written in Gherkin syntax, which provides keywords like Feature, Scenario, Given, When, Then, And, and But to describe a workflow. Because BDD focuses on what the system does rather than how it is implemented, it complements TDD by aligning tests with business intent. The Given-When-Then pattern maps directly onto AAA: Given corresponds to Arrange, When to Act, and Then to Assert.

Test Doubles

Tests often need to replace real dependencies to isolate the code under test. These replacements are called test doubles, a term coined by Gerard Meszaros in xUnit Test Patterns, and come in five varieties. A dummy object is a placeholder passed around but never used, often just filling required parameters. A stub provides canned responses to calls and is used for state verification, where the test checks the result of the system under test. A spy wraps a real function so it executes normally while recording information such as call count, arguments, and return values. A mock is pre-programmed with expectations and is used for behavior verification, asserting that specific methods were called with specific arguments. A fake is a working implementation with shortcuts, such as an in-memory database, that is more realistic than a stub but lighter than a real dependency.

Distinguishing between mocks and stubs is one of the more common points of confusion in testing. A stub simply answers calls with predefined values, allowing the test to inspect the output of the system under test; for example, a getUser stub might always return { name: "Alice" }. A mock, in contrast, asserts how the system under test interacted with it; the test might verify that getUser was called exactly once with ID 5. Mocks and stubs therefore answer different questions: mocks verify behavior, while stubs set up state.

Spies occupy a middle ground. Rather than replacing the implementation as mocks and stubs do, a spy wraps an existing function so that it can be observed without altering its behavior, which is useful for verifying side effects. In Jest, jest.spyOn(obj, 'method') creates a spy that still runs the original code while recording each call. Fakes and dummies also have distinct uses: fakes such as a HashMap-backed FakeUserRepository provide working logic without real dependencies, while dummies are simpler still, used only to satisfy method signatures. Beyond these isolated doubles, contract testing verifies that services agree on their interfaces. In a consumer-driven approach, the consumer defines the expected interactions, and the provider verifies its implementation against those contracts; the popular Pact framework enables this kind of testing without requiring full end-to-end integration.

Writing and Structuring Tests

Well-structured tests follow clear patterns that aid readability and maintenance. The AAA pattern, which stands for Arrange, Act, Assert, divides a test into three phases: Arrange sets up the test data, mocks, and preconditions; Act executes the code under test; and Assert verifies the expected outcome. For example, a calculator test might arrange by instantiating a Calculator, act by calling add(2, 3), and then assert the result equals 5. This simple structure makes the intent of each test obvious and reduces accidental complexity.

Assertions are the heart of every test, and matchers are the methods that define how values are compared. Common matchers include toBe for strict equality, toEqual for deep equality, toContain for checking that an array or string includes a value, toThrow for verifying that an exception is raised, toBeGreaterThan for numeric comparisons, and toMatch for pattern matching. Frameworks such as Jest, Chai, AssertJ, and Hamcrest provide these matchers in different languages. A subtle but important distinction is between assertEquals, which checks value equality, and assertSame, which checks reference equality. Two separately constructed String objects with identical content would satisfy assertEquals but fail assertSame. In Jest, toBe corresponds roughly to assertSame, while toEqual resembles assertEquals.

Several techniques further reduce duplication and broaden coverage. Parameterized testing runs the same test logic with different input and output pairs, allowing frameworks like Jest's test.each, JUnit's @ParameterizedTest, or pytest's @pytest.mark.parametrize to multiply coverage without multiplying code. Snapshot testing captures the output of a component or function and compares subsequent runs against a stored reference, a popular technique for UI components in React with toMatchSnapshot. Although useful, snapshot tests can become brittle if overused because any unintended visual change triggers a failure. Property-based testing takes a different angle: instead of specific examples, the developer defines properties, or invariants, that the code must satisfy for all inputs, and the framework generates hundreds or thousands of random test cases, shrinking any failing input to the minimal example. Tools such as QuickCheck, Hypothesis, and fast-check embody this approach.

Test Infrastructure and Frameworks

Behind every robust test suite lies infrastructure that prepares and cleans up the environment. Test fixtures provide a fixed, known starting state by supplying predefined data, object instances in a known configuration, or environment settings. The arrangement of fixtures is controlled through setup and teardown hooks. beforeEach and afterEach run before and after every test in a suite, while beforeAll and afterAll run once per suite, enabling fine-grained control over test isolation. Good fixtures are isolated, repeatable, and independent of test execution order, ensuring that each test begins with a clean slate.

Test isolation extends beyond fixtures: it means each test is fully independent, depending on no other test and affecting no other test. There should be no shared mutable state, and tests should be able to run in any order and still pass. Violations of isolation are a major source of flaky tests, which pass or fail intermittently without any change to the code. A related concept, test independence, requires that each test creates its own data and cleans it up rather than relying on the work of other tests. Hooks like beforeEach and afterEach are the primary mechanism for guaranteeing this property.

Tests are organized into test suites, which are collections of related test cases grouped by feature, module, or test type. Suites can be nested and run as units, allowing testers to target subsets of the codebase. The actual discovery, execution, and reporting of these tests is the job of a test runner, which finds test files by naming convention or configuration, executes them (often in parallel), and reports pass/fail status and timing. Examples include jest for JavaScript, pytest for Python, gradle test for Java, phpunit for PHP, and go test for Go. Each major language has its own prevalent framework: Jest for JavaScript offers zero-config setup with built-in mocking, snapshot support, and coverage reporting; pytest for Python favors plain assert statements and powerful fixtures with a rich plugin ecosystem; JUnit 5, or Jupiter, is the standard for Java with annotations like @Test, @BeforeEach, @AfterAll, and @ParameterizedTest; and Mocha, a flexible Node.js framework, uses describe and it syntax but typically requires separate libraries like Chai for assertions and Sinon for mocking.

Measuring Test Quality

Coverage metrics attempt to quantify how thoroughly a test suite exercises the codebase. Test coverage is the umbrella term, commonly broken down into line coverage, which measures the percentage of lines executed during testing; branch coverage, which measures whether each branch of every decision point (if, else, switch, ternary) has been taken; function coverage, which tracks the percentage of functions called; and statement coverage, which tracks the percentage of statements executed. Tools like Istanbul or nyc for JavaScript, coverage.py for Python, and JaCoCo for Java produce these reports.

Branch coverage catches what line coverage misses. Consider a simple if-else block: a single test with a positive input executes the lines in the if branch and gives 50 percent line coverage of that block but only 50 percent branch coverage, because the else branch was never taken. Full branch coverage requires exercising both sides. Path coverage is even more rigorous: it counts every possible execution path through a function. With n independent if statements, there are \(2^n\) paths, and the number of paths grows exponentially with branching, which is why 100 percent path coverage is rarely achievable in real codebases.

Regardless of which metric is used, high coverage does not guarantee good tests. Code that executes without meaningful assertions inflates coverage while leaving bugs undetected, and coverage tends to overlook edge cases, error handling, and concurrency. Worse, an incentive to hit a coverage target can lead developers to write trivial tests just to bump the number. A more useful approach is to aim for meaningful coverage of around 80 to 90 percent, backed by high-quality assertions. Mutation testing sharpens this idea further: it deliberately introduces small changes, or mutants, such as flipping \(+\) to \(-\), changing \(==\) to \(!=\), or removing statements, and checks whether tests catch them. A mutant killed by a failing test indicates that the suite has teeth, while a surviving mutant exposes a gap. The mutation score is the ratio of killed to total mutants, and tools like Stryker, PITest, and mutmut implement this evaluation. Accumulation of weak tests, slow tests, and tightly coupled tests is called test debt, a form of technical debt best managed by regularly reviewing test quality, deleting low-value tests, refactoring test code, and treating tests with the same care as production code.

Specialized Testing and CI/CD

Beyond the unit/integration/E2E tiers, several specialized testing strategies target specific risks. Regression testing re-runs existing suites after changes to catch unintended side effects and is often automated in CI/CD pipelines. It can be performed as a full regression, running every test; as a selective regression, running only tests related to the modified code; or as a risk-based regression, prioritizing tests for the most critical features. Smoke testing, also called build verification testing, is a quick, shallow check that the most critical functions still work, answering the question of whether the build even boots before deeper testing begins. Sanity testing is a narrower, deeper check performed after a specific change to confirm that a particular bug fix or feature behaves correctly. Smoke is therefore broad and shallow, while sanity is narrow and deep.

Performance testing evaluates how a system behaves under various conditions. Load testing simulates expected concurrent users or requests to verify the system meets its performance baselines in response time, throughput, and error rates. Stress testing pushes the system beyond its normal capacity, while spike testing introduces sudden large load increases. Endurance testing, sometimes called soak testing, applies sustained load over an extended period to expose issues like memory leaks. Tools such as JMeter, k6, Gatling, and Locust enable these scenarios.

Flaky tests are a persistent operational headache. They pass or fail intermittently without any change to the code, often because of shared mutable state, timing or race conditions in async code, external dependency failures, or order-dependent assertions. CI/CD teams handle flaky tests by quarantining them into separate suites that do not block deployments, configuring retries for transient failures, tracking their history to find patterns, and ultimately fixing root causes rather than masking them with retries. A coherent CI/CD testing strategy runs the fastest tests first: pre-commit hooks perform linting and unit tests for immediate developer feedback; the CI build runs unit and integration tests; staging executes E2E and smoke tests; pre-deploy runs contract tests and security scans; and post-deploy uses smoke tests and synthetic monitoring. The underlying principle, often called shift left, is to catch bugs as early as possible because a defect found in development costs roughly an order of magnitude less to fix than one discovered in production.

Frequently asked questions

What is a unit test?

A unit test verifies the behavior of a single, isolated piece of code (e.g., a function or method). Key characteristics:
  • Tests one logical unit in isolation
  • Dependencies are mocked or stubbed
  • Runs fast (milliseconds)
  • Deterministic and repeatable
Example in Jest:
test('add returns sum', () => { expect(add(2, 3)).toBe(5); });

What is TDD (Test-Driven Development)?

TDD is a development practice where you write tests before writing production code. The cycle is:
  1. Red: Write a failing test for the desired behavior
  2. Green: Write the minimum code to make the test pass
  3. Refactor: Clean up the code while keeping tests green
Benefits: better design, fewer bugs, living documentation, and confidence in refactoring.

What is a spy in testing?

A spy wraps a real function, allowing it to execute normally while also recording information about its calls:
  • Tracks call count, arguments, return values
  • Does not replace the implementation (unlike mocks/stubs)
  • Useful for verifying side effects without changing behavior
Example in Jest:
const spy = jest.spyOn(obj, 'method');
expect(spy).toHaveBeenCalledWith('arg');

What is test coverage?

Test coverage measures how much of your code is executed during testing. Common metrics:
  • Line coverage: % of lines executed
  • Branch coverage: % of decision branches taken
  • Function coverage: % of functions called
  • Statement coverage: % of statements executed
Tools: Istanbul/nyc (JS), coverage.py (Python), JaCoCo (Java). High coverage ≠ good tests.

What are assertion patterns and matchers?

Assertions verify expected outcomes. Matchers are methods that define how values are compared:
  • toBe(value) — strict equality
  • toEqual(obj) — deep equality
  • toContain(item) — array/string inclusion
  • toThrow() — exception thrown
  • toBeGreaterThan(n) — numeric comparison
  • toMatch(/regex/) — pattern matching
Frameworks: Jest, Chai, AssertJ, Hamcrest.

What is setup and teardown in testing?

Setup prepares the test environment before tests run. Teardown cleans up afterward.
  • beforeEach: Runs before each test (reset state)
  • afterEach: Runs after each test (cleanup)
  • beforeAll: Runs once before the suite
  • afterAll: Runs once after the suite
Purpose: ensure test isolation — each test starts with a clean slate and doesn't affect other tests.

What is sanity testing?

Sanity testing is a focused, narrow test after a specific change to verify the fix works:
  • Subset of regression testing
  • Verifies a particular bug fix or feature change
  • Not exhaustive — just checks the specific area
Smoke vs Sanity:
Smoke = broad, shallow (does the whole system boot?)
Sanity = narrow, deep (does this specific fix work correctly?)

What is contract testing?

Contract testing verifies that services (consumer and provider) agree on the API interface:
  • Consumer-driven: Consumer defines expected interactions
  • Provider verifies: Provider tests against consumer contracts
  • Catches breaking API changes early
Tool: Pact is the most popular contract testing framework.
Unlike integration tests, each side is tested independently using the shared contract.

What is CI/CD testing strategy?

A CI/CD testing strategy defines what tests run at each pipeline stage:
  • Pre-commit: Linting, unit tests (fast feedback)
  • CI build: Unit + integration tests
  • Staging: E2E tests, smoke tests
  • Pre-deploy: Contract tests, security scans
  • Post-deploy: Smoke tests, synthetic monitoring
Goal: shift left — catch bugs as early as possible. Fast tests run first; slow tests gate later stages.

What is a test runner?

A test runner is the tool that discovers, executes, and reports test results:
  • Finds test files by naming convention or config
  • Executes tests (optionally in parallel)
  • Reports pass/fail status and timing
  • Supports filtering, watching, and CI integration
Examples: jest (JS), pytest (Python), gradle test (Java), phpunit (PHP), go test (Go).

Drill this topic

50 flashcards on Testing Tdd — free, no signup needed to start.

Study Testing Tdd flashcards

LearnWiki pages are generated with AI assistance from LearnCoachAssist's reviewed study catalog and may contain errors — verify anything critical against your course materials.