Testing

Subpage of SWE for Pros

Sophisticated software systems under the hood

Here, I break down each major testing concept, explain its purpose, provide practical examples, and discuss best practices. This guide will be organized by testing technique, with a focus on clarity, actionability, and real-world relevance.

Test Pyramid and Testing Strategies

What is the Test Pyramid?

The Test Pyramid is a visual model introduced by Martin Fowler to describe the ideal distribution of tests in a software project. It emphasizes:

  • Many unit tests (fast, isolated, cheap to write and run).
  • Fewer integration tests (slower, test interactions between components).
  • Even fewer end-to-end tests (slowest, test the entire system).

Why Does It Matter?

  • Speed: Unit tests run in milliseconds, while end-to-end tests can take minutes.
  • Feedback: Faster tests provide quicker feedback during development.
  • Maintainability: A heavy reliance on end-to-end tests leads to brittle, slow test suites.

Testing Strategies

StrategyScopeSpeedPurposeExample Tools
Unit TestingSingle function/classFastValidate logic in isolationJUnit, pytest, Jest
Integration TestingModule interactionsMediumTest component interactionsTestNG, pytest, Postman
End-to-End TestingEntire systemSlowValidate user journeysSelenium, Cypress, Playwright
Property-BasedInput/Output behaviorFastValidate invariantsHypothesis, QuickCheck
FuzzingRandom inputsSlowFind edge cases and crashesAFL, libFuzzer, Honggfuzz

Unit Testing

What is Unit Testing?

Unit testing involves testing individual units (functions, methods, classes) in isolation to ensure they work as expected.


Key Principles

  1. Isolation: Each test should focus on one unit.
  2. Determinism: Tests should produce the same result every time.
  3. Speed: Unit tests should run quickly (milliseconds).
  4. Readability: Tests should be easy to understand and maintain.

Example: Testing a Calculator in Python

python
Copy
# calculator.py
def add(a,b):
    return a+ b

def subtract(a,b):
    return a- b

# test_calculator.py
import unittest
from calculatorimport add, subtract

class TestCalculator(unittest.TestCase):
    def test_add(self):
        self.assertEqual(add(2,3),5)
        self.assertEqual(add(-1,1),0)

    def test_subtract(self):
        self.assertEqual(subtract(5,3),2)
        self.assertEqual(subtract(0,0),0)

if __name__== "__main__":
    unittest.main()

Best Practices

  • Name tests clearly: Use test_add_positive_numbers instead of test1.
  • Test edge cases: Zero, negative numbers, None, empty inputs.
  • Avoid logic in tests: Tests should be straightforward assertions.
  • Use assertions liberally: Each test should have at least one assertion.

Mocking and Stubbing

What is Mocking?

Mocking replaces real dependencies (e.g., databases, APIs) with controlled objects to isolate the unit under test.

  • Mock: A fake object that records interactions (e.g., was a method called?).
  • Stub: A fake object that returns predefined responses.

When to Use Mocking

  • External services (APIs, databases).
  • Slow or non-deterministic dependencies (e.g., time, randomness).
  • Hardware or third-party libraries.

Example: Mocking an API Call in Python

python
Copy
# weather.py
import requests

def get_weather(city):
    response= requests.get(f"https://api.weather.com/{city}")
    return response.json()["temperature"]

# test_weather.py
from unittest.mockimport patch
import weather

@patch("weather.requests.get")
def test_get_weather(mock_get):
    # Configure the mock to return a fake response
    mock_get.return_value.json.return_value= {"temperature":25}

    # Call the function
    temp= weather.get_weather("Singapore")

    # Assert the mock was called correctly
    mock_get.assert_called_once_with("https://api.weather.com/Singapore")
    assert temp== 25

Best Practices

  • Don’t over-mock: Only mock what’s necessary for isolation.
  • Use mocks for behavior verification: Check if a method was called with the right arguments.
  • Use stubs for state testing: Return fixed values to test logic.
  • Avoid mocking everything: If you’re mocking too much, consider integration tests.

Mocking Frameworks

Testing Tools by Language

LanguageFramework
Pythonunittest.mock
JavaMockito
JavaScriptJest, Sinon
C#Moq, NSubstitute

Integration Testing

What is Integration Testing?

Integration testing verifies that multiple components or modules work together as expected.


Types of Integration Testing

  1. Component Integration: Test interactions between classes/modules.
  2. API Integration: Test API endpoints and their dependencies.
  3. Database Integration: Test queries and data consistency.
  4. Microservices Integration: Test communication between services.

Example: Testing a Flask API with a Database

python
Copy
# app.py (Flask)
from flaskimport Flask, jsonify
from flask_sqlalchemyimport SQLAlchemy

app= Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"]= "sqlite:///test.db"
db= SQLAlchemy(app)

class User(db.Model):
    id = db.Column(db.Integer,primary_key=True)
    name= db.Column(db.String(80))

@app.route("/users/<int:user_id>")
def get_user(user_id):
    user= User.query.get(user_id)
    return jsonify({"name": user.name})

# test_app.py
import pytest
from appimport app, db, User

@pytest.fixture
def client():
    app.config["TESTING"]= True
    app.config["SQLALCHEMY_DATABASE_URI"]= "sqlite:///:memory:"
    with app.test_client()as client:
        with app.app_context():
            db.create_all()
            db.session.add(User(name="Alice"))
            db.session.commit()
        yield client
        db.drop_all()

def test_get_user(client):
    response= client.get("/users/1")
    assert response.status_code== 200
    assert response.json== {"name":"Alice"}

Best Practices

  • Test real interactions: Use a test database or sandbox environment.
  • Isolate tests: Each test should set up and tear down its own data.
  • Test error cases: Invalid inputs, missing data, network failures.
  • Use fixtures: Reuse setup/teardown logic (e.g., pytest fixtures).

Integration Testing Tools

Language/FrameworkTool
Pythonpytest, unittest
JavaTestNG, JUnit
JavaScriptJest, Supertest
DatabasesTestcontainers
APIsPostman, RestAssured

Property-Based Testing

What is Property-Based Testing?

Instead of writing explicit test cases, you define properties (invariants) that should hold true for all valid inputs. The framework generates random inputs to test these properties.


Example: Testing a Sorting Function with Hypothesis (Python)

python
Copy
from hypothesisimport given
from hypothesis.strategiesimport lists, integers

def sort_list(lst):
    return sorted(lst)

@given(lists(integers()))
def test_sort_list_preserves_length(lst):
    assert len(sort_list(lst))== len(lst)

@given(lists(integers()))
def test_sort_list_is_sorted(lst):
    sorted_lst= sort_list(lst)
    assert sorted_lst== sorted(sorted_lst)

When to Use Property-Based Testing

  • Complex logic: Where edge cases are hard to predict.
  • Mathematical properties: E.g., commutativity, associativity.
  • Data transformations: E.g., serialization/deserialization.
  • Input validation: Ensure functions handle all valid inputs.

Property-Based Testing Frameworks

LanguageFramework
PythonHypothesis
Javajqwik
JavaScriptfast-check
HaskellQuickCheck
Rustproptest

Best Practices

  • Start small: Focus on one property at a time.
  • Combine with example-based tests: Use both for critical logic.
  • Use custom strategies: Generate realistic data (e.g., valid emails, dates).
  • Shrink failures: Frameworks like Hypothesis will simplify failing inputs.

Fuzzing

What is Fuzzing?

Fuzzing is an automated testing technique that feeds random, malformed, or unexpected inputs to a program to find crashes, memory leaks, or security vulnerabilities.


Types of Fuzzing

  1. Dumb Fuzzing: Random inputs with no structure.
  2. Smart Fuzzing: Inputs generated based on known formats (e.g., file headers, API schemas).
  3. Mutation-Based Fuzzing: Modify existing inputs (e.g., flip bits, insert junk).
  4. Generation-Based Fuzzing: Generate inputs from scratch using grammars.

Example: Fuzzing a Python Function with atheris

python
Copy
# Install atheris: pip install atheris
import atheris
import sys

def parse_data(data):
    if not isinstance(data,str):
        raise ValueError("Expected string")
    if len(data)< 10:
        raise ValueError("Data too short")
    return data.upper()

@atheris.instrument_func
def test_parse_data(fuzzer,data):
    try:
        parse_data(data)
    except ValueError:
        pass  # Expected for invalid inputs

atheris.Setup(sys.argv, test_parse_data)
atheris.Fuzz()

Run with:

bash
Copy
python fuzz_test.py

When to Use Fuzzing

  • Security testing: Find buffer overflows, SQL injection, etc.
  • Parsers: Test JSON/XML/CSV parsers with malformed data.
  • Network protocols: Test servers/clients with invalid packets.
  • File formats: Test image/audio/video parsers.

Fuzzing Tools

ToolLanguageUse Case
AFLC/C++General-purpose fuzzing
libFuzzerC/C++In-process fuzzing
HonggfuzzC/C++Multi-platform fuzzing
atherisPythonPython fuzzing
HypothesisPythonProperty-based fuzzing
RadamsaAnyGeneral-purpose fuzzer

Best Practices

  • Start with small inputs: Avoid overwhelming the program.
  • Monitor coverage: Use tools like gcov or llvm-cov to track progress.
  • Use sanitizers: Enable AddressSanitizer (ASan), UndefinedBehaviorSanitizer (UBSan).
  • Fuzz early and often: Integrate fuzzing into CI/CD pipelines.

Example: Fuzzing a C Program with AFL

  1. Install AFL:
    bash
    Copy
    sudo apt-get install afl
  2. Write a simple program (test.c):
    c
    Copy
    #include <stdio.h>
    #include <string.h>
    
    int main(int argc,char **argv) {
        if (argc< 2)return 1;
        char *input= argv[1];
        if (strlen(input)> 100) {
            printf("Input too long!\n");
            return 1;
        }
        printf("Input: %s\n", input);
        return 0;
    }
  3. Compile with AFL:
    bash
    Copy
    afl-gcc test.c -o test
  4. Run AFL:
    bash
    Copy
    mkdir inputs outputs
    echo "test" > inputs/seed.txt
    afl-fuzz -i inputs -o outputs ./test

Contract Testing

What is Contract Testing?

Contract testing ensures that independent services (e.g., microservices) can communicate without requiring both to be deployed. It focuses on the interaction between services, not their internal logic.


How It Works

  1. Consumer (e.g., frontend) defines expectations for an API.
  2. Provider (e.g., backend) verifies it meets those expectations.
  3. Tests run in isolation, using stubs for the other service.

Example: Pact (JavaScript)

  1. Install Pact:
    bash
    Copy
    npm install @pact-foundation/pact
  2. Consumer test (consumer.test.js):
    javascript
    Copy
    const {Pact }= require("@pact-foundation/pact");
    const provider = new Pact("http://localhost:3000");
    
    describe("API Contract", ()=> {
        beforeAll(()=> provider.setup());
        afterEach(()=> provider.verify());
        afterAll(()=> provider.finalize());
    
        it("gets a user",async ()=> {
            await provider.addInteraction({
                state: "user exists",
                uponReceiving: "a request for user 1",
                withRequest: {
                    method: "GET",
                    path: "/users/1",
                },
                willRespondWith: {
                    status: 200,
                    body: {id: 1,name: "Alice" },
                },
            });
    
            const response = await fetch("http://localhost:3000/users/1");
            const user = await response.json();
            expect(user.name).toBe("Alice");
        });
    });
  3. Provider verification (provider.test.js):
    javascript
    Copy
    const {Verifier }= require("@pact-foundation/pact");
    const path = require("path");
    
    describe("Pact Provider Verifier", ()=> {
        it("validates the contract", ()=> {
            const opts = {
                provider: "UserService",
                providerBaseUrl: "http://localhost:3000",
                pactUrls: [path.resolve(__dirname,"../pacts/consumer-userservice.json")],
            };
            return new Verifier(opts).verifyProvider();
        });
    });

Contract Testing Tools

ToolLanguageUse Case
PactMulti-languageMicroservices, APIs
Spring Cloud ContractJavaSpring Boot microservices
Postman Contract TestingAnyAPI schemas (OpenAPI)

Best Practices

  • Start with consumer tests: Define expectations first.
  • Keep contracts small: Focus on one interaction per test.
  • Version contracts: Update contracts when APIs change.
  • Integrate with CI/CD: Run contract tests in pipelines.

Mutation Testing

What is Mutation Testing?

Mutation testing evaluates the quality of your test suite by introducing small changes (mutations) to your code and checking if your tests catch them.


How It Works

  1. Mutate the code: Change operators (e.g., + to ), remove lines, or alter conditions.
  2. Run tests: If a mutation isn’t caught by a test, it’s a survived mutation.
  3. Calculate mutation score:
    text
    Copy
    Mutation Score = (Killed Mutations) / (Total Mutations)

Example: Mutation Testing with mutmut (Python)

  1. Install mutmut:
    bash
    Copy
    pip install mutmut
  2. Run mutation testing:
    bash
    Copy
    mutmut run
  3. Example output:
    text
    Copy
    ----------------------------------------------------------------------
    Results:
    ----------------------------------------------------------------------
    Killed: 10
    Survived: 2
    Timeout: 0
    Total: 12
    Mutation score: 83.33%
  4. Apply mutations to see which tests fail:
    bash
    Copy
    mutmut apply 1  # Apply mutation 1
    pytest         # Run tests
    mutmut revert 1 # Revert mutation

Mutation Testing Tools

ToolLanguageDescription
mutmutPythonSimple mutation testing
StrykerJavaScriptMutation testing for JS/TS
PitestJavaMutation testing for Java
MutPyPythonAcademic mutation tool

Best Practices

  • Start small: Run on critical modules first.
  • Focus on high mutation scores: Aim for >80%.
  • Ignore trivial mutations: Some mutations (e.g., changing 1 + 1 to 1 + 2) are meaningless.
  • Integrate with CI: Run mutation tests periodically.

Test-Driven Development (TDD)

/ Continue

Follow the technical trail.

Use the dense notes as the source material, then move through the guided route, writing, or project proof when you want a cleaner entry point.