Why testing?
I used to view tests as a “nice to have”, but I’ve come to realize they are a “must have” for any serious software project. Here is why:
- Saves time in the long run.
- Encourages better design and modularity.
- Confidence boost, specially for new contributors.
- Serves as documentation for the expected behavior of the code.
My personal motivation is because I hate to manually test things, and I want to automate as much as possible. So I want to share my learnings and some practical tips to get started.
Test Driven Development (TDD)
Is something I want to explore more, but it sounds a good way to kill two birds with one stone. The idea is to write tests before writing the actual code, which forces you to think about the requirements and design upfront.
To give it a try myself, I found this simple workflow:
- Write a failing test that defines a function or improvement you want to implement.
- Write the minimum amount of code needed to make the test pass.
- Refactor the code while keeping the tests passing.
- Repeat.
I heard some mixed opinions about TDD, is a tradeoff as with everything else. But I think is worth trying to see if it works for you.
Testing Frameworks
There are many testing frameworks available for different programming languages. My favorites are:
- JavaScript/TypeScript:
jest, or Bun’s built-in test runner - Go:
testifyortesting(built-in) - Python:
pytestorunittest(built-in)
Why use a testing framework?
They provide useful features out of the box, such as test discovery, assertions, setup and teardown hooks, mocking/stubbing capabilities, reporting, and so on.
Approaches to testing
There are different levels of testing, each with its own purpose:
- Unit tests: test individual functions or methods in isolation.
- Integration tests: test how different modules or services work together.
- End-to-end (E2E) tests: test the entire application flow from start to finish.
The testing pyramid is a concept that suggests having more unit tests than integration tests, and more integration tests than E2E tests. This is because unit tests are faster and easier to maintain, while E2E tests are slower and more brittle.
Mocking and Stubbing
When writing tests, you might need to replace certain parts of your code with mock or stub implementations. This helps isolate the unit being tested and control its dependencies.
- Mocking: creating a fake version of an object or function that simulates its behavior. Mocks can also verify that certain methods were called with specific arguments.
- Stubbing: providing predefined responses for certain methods or functions, without simulating their behavior. Stubs are simpler than mocks and are mainly used to return specific values.
When to use each one depends on the context, but generally, use stubs when you just need to return specific values, and use mocks when you need to verify interactions.
TIP
Mocking is especially useful for cumbersome dependencies like databases, external APIs, or authentication systems that aren’t relevant to the test at hand.
Example of mocking in JavaScript with Jest
const processData = require('./processData');
const fetchData = require('./fetchData');
jest.mock('./fetchData');
test('processData handles fetched data correctly', async () => {
fetchData.mockResolvedValue({ data: 'mocked data' });
const result = await processData();
expect(result).toEqual('processed: mocked data');
});
In this example, what really matters is that processData behaves correctly when fetchData returns certain data, so we mock fetchData to return a controlled response. This way we isolate the test for processData.
Some challenges with mocking/stubbing to be aware of:
- Over-mocking: mocking too much can lead to tests that don’t reflect real-world scenarios.
- Maintenance overhead: mocks and stubs need to be updated when the real implementations change.
Fixtures
Fixtures are predefined data sets or configurations used in tests. They are particularly useful for avoiding repetitive setup code and ensuring consistency across tests.
Each language has its own way of implementing fixtures, in Python with pytest, you can use the @pytest.fixture decorator:
import pytest
@pytest.fixture(scope="function")
def user_database():
"""Setup: Create a fresh database"""
db = {"users": []}
print("Creating database")
yield db # Tests use this
# Teardown: Clean up
print("Deleting database")
db.clear()
def test_add_user(user_database):
user_database["users"].append({"id": 1, "name": "Alice"})
assert len(user_database["users"]) == 1
In this example, the user_database fixture sets up a fresh database for each test function and cleans it up afterward.
Topics to explore
This is a personal list of topics I want to understand better about testing, and eventually include what I learn in this post.
- Quality assurance (QA): ensuring the overall quality of the software through various testing methods
- Mutation testing: testing by introducing small changes to the code to see if tests catch them
- Fuzzing: testing with random or unexpected inputs
- Property-based testing: testing based on properties or invariants of the code
- Behavior-driven development (BDD): writing tests in a more human-readable format, focusing on behavior rather than implementation details