1. The Cycle of TDD
- Write a Test First
Before writing implementation code, write a test that specifies how the code should behave. This test will typically fail at first because the code hasn't been implemented yet.
- Write Just Enough Code
Write only the code necessary to make the test pass. Do not add features beyond the scope of the test.
- Refactor
Once the test passes, refactor the code to improve its quality without changing its functionality.
2. TDD Mindset
- Start with Failure (Fail First):
Get used to writing tests for features that do not yet exist. This requires discipline and the mindset that "failure is normal" at the beginning of the process.
- Small, Focused, and Iterative
Avoid trying to implement large features all at once. TDD encourages breaking work into small parts and focusing on one thing at a time.
- Design by Tests
TDD is not just about writing tests; it's also about design. Writing tests before code forces you to think about the best way to design APIs or functions to make them easy to test.
3. Testing Approaches in TDD
- Unit Test
Focus on the smallest unit of code, typically a function or class.
- Ensure tests are deterministic (the same result every time they run).
- Use mocking or stubbing to isolate external dependencies.
- Integration Test
Test interactions between multiple units.
- Ensure modules work well together.
- Focus on data flow between components.
- End-to-End (E2E) Test
Test the entire system from the user's perspective.
- Use this only for key features, as these tests are time-consuming.
4. Strategies to Avoid TDD Failures
Write Accurate Tests
Don't just test the "happy path." Write tests for edge cases or error scenarios:
- Zero values (0)
- Empty strings
- Invalid inputs (e.g., null or undefined)
- Use Coverage Reports
Ensure your code is thoroughly tested. Use tools like Istanbul, Jest, or SonarQube to check test coverage.
- Manage Dependencies
Avoid tight coupling between modules. Use techniques like:
- Mocking to replace dependencies with fake versions.
- Inversion of Control (IoC) or dependency injection to make components easier to test.
5. Organizing Tests
a. Clear Test Structure
Use the Arrange-Act-Assert (AAA) format:
- Arrange: Set up all data, dependencies, or initial conditions.
- Act: Call the function or perform the action you want to test.
- Assert: Verify that the result matches the expected outcome.
b. Use Descriptive Naming
Test names should be clear and explain what is being tested.
c. Organize Test Folders
Keep tests in a well-organized folder structure.
6. Design Principles for TDD
a. Single Responsibility Principle (SRP)
Ensure functions or classes have only one responsibility, making them easier to test.
b. Open/Closed Principle
Design components to be extensible without modifying existing code.
c. Dependency Injection
Avoid creating direct dependencies inside a class. Instead, inject dependencies from the outside.
7. Advanced Concepts in TDD
a. Test Doubles
- Mock: Replace real objects with fake ones to control behavior and verify interactions.
- Stub: Provide fake responses for a function.
- Spy: Track how a function is called.
b. Behavior-Driven Development (BDD)
An advanced form of TDD, where tests are written in a human-readable language, often using frameworks like Cucumber.
Scenario: Adding two numbers
Given I have numbers 2 and 3
When I sum the numbers
Then the result should be 5
8. Advanced Tips
1. Outside-In Development (London School TDD)
Approach: Start with high-level tests like integration or acceptance tests before moving to unit testing.
Main Principle: Focus on behavior rather than implementation. Develop the system based on how components should interact.
Example:
Start with testing an API endpoint (e.g., REST or GraphQL), then move to the service, repository, and class-level implementation.
Benefits:
- Avoid over-engineering by ensuring only necessary code is written.
- Focus on functional outcomes.
2. Mutation Testing
Concept: Evaluate test quality by introducing small changes (mutations) in the source code and checking if tests catch those changes.
If tests don't fail after a mutation, it indicates a lack of coverage or precision.
Tools:
- JavaScript: Stryker
- Java: Pitest
Benefits:
- Measure how robust your tests are against code changes.
- Identify high-risk areas for bugs.
3. Mocking and Stubbing Best Practices
When testing components that depend on external services or APIs, use mocking and stubbing.
General Principles:
- Use mocks to verify interactions (e.g., whether a method was called).
- Use stubs to provide fixed responses (dummy data) from dependencies.
4. Property-Based Testing
Concept: Instead of testing specific cases, define general properties or rules that must always hold true.
Example: "The result of merging two arrays must always equal the combined length of both arrays."
Tools:
- JavaScript: Fast-check
Benefits:
- Discover hidden bugs by testing many input combinations.
- Ideal for complex algorithms like sorting or encryption.
5. Golden Master Testing
Concept: Used for legacy systems or complex code without prior documentation or tests.
Take a snapshot of the current behavior or output (golden master) and use it as a baseline for future tests.
Benefits:
- Enables refactoring of old systems without breaking functionality.
- Highly effective for large or monolithic systems.
6. Contract Testing
Concept: Ensure that the contract between two services, such as an API and its consumer, is not broken.
Tools:
- Pact (for API contract testing).
Benefits:
- Increases confidence when making changes or iterating on dependent services.
- Reduces integration risks.
7. Exploratory Testing with TDD
Approach: Combine TDD principles with manual exploration for new features or experimental algorithms.
Method:
- Write initial tests to "lock in" known behavior.
- Use additional tests to explore how the code reacts to new inputs or scenarios.
Benefits:
- Uncover edge cases that might have been missed.
- Accelerate validation of new functionality.
8. Delayed Implementation
For cases where a feature or function cannot yet be implemented (e.g., due to unfinished dependencies), use a test placeholder.
describe('New Feature', () => {
it('should handle specific edge case (TODO: implement)', () => {
// Tes sementara
expect(true).toBe(true);
});
});
Benefits:
- Reminds the team to complete pending parts.
- Breaks work into manageable steps.
9. Working with Legacy Code
If legacy code lacks tests:
- Add Characterization Tests to "lock in" current behavior.
- Write tests for small parts of the code you plan to modify.
- Gradually refactor while ensuring tests pass.
Benefits:
- Introduces TDD into systems that were not initially designed for it.
- Reduces refactoring risks in legacy systems.
Top comments (1)
Awesome! ,for the next time, please make an article to test the computed, signal and effect