Test First, Code Later

Daffa Muhammad Faizan
8 min readMay 5, 2024

--

How in the world would you do that, you ask? For developers used to diving straight into coding, Test-Driven Development (TDD) seems downright counterintuitive at first. But once you grasp the general concept, it becomes a game-changer. Test-Driven Development (TDD) challenges you to think in terms of outcomes before implementations. Imagine knowing your code works flawlessly before even executing it. TDD can help you to do just that. By first writing tests that describe the expected behavior of your code, you establish a safety net against future bugs. It’s an approach that not only ensures your code meets requirements but also makes your code cleaner.

What is TDD?

As a certain LLM explains it, Test-driven development (TDD) is a software development approach where you write tests for your code before you actually write the code itself. The process typically involves the following steps:

  1. Write a Test: You begin by writing a test that describes the functionality you want to implement. This test will initially fail because you haven’t written any code yet.
  2. Write the Code: Once you have a failing test, you write the minimum amount of code necessary to make that test pass. This often means implementing the simplest solution to satisfy the test case.
  3. Run the Test: After writing the code, you run the test suite to see if the new test passes. If it passes, you can be confident that the code you just wrote works as expected. If it fails, you need to revise your code until it passes.
  4. Refactor: Once all tests pass, you can refactor your code to improve its structure, readability, and efficiency. Refactoring ensures that your code remains clean and maintainable while preserving its functionality.
  5. Repeat: You continue this cycle of writing tests, writing code to pass the tests, running the tests, and refactoring until you have implemented all the required functionality.

What should this feature do?

A question that you, as a developer, should ask yourself before starting to code. You should know the feature you are making inside and out, like a father to a baby. By knowing what the feature does, it’ll make it a lot easier when developing. But how would you know?

Requirements. As simple as that. What do you intend for the user to do? What is the end goal? What is the purpose? How would they be able to achieve that goal? After asking all these questions and knowing the requirements, you can start writing test cases containing the intended request body and the expected responses.

Make a ton. Make positive cases, negative ones, heck, even cases that might be out of the ordinary. These will help you in your process of implementing TDD. Where should you write your cases, you might ask? You can make’em anywhere, really. The examples below showcase a few tools or softwares that might be of use to you.

Use Google Sheets. It’s customizable, clean, and the most important thing, it’s free! The example above showcases an API Endpoint and the test cases of that particular feature. We determine what tests we should make, their intended requests and also the expected responses. After development, we comeback to the request body, copy it, test it on the dev API, and voila! Turns out it was as expected!

Ain’t nothin’ like Postman. Understanding Postman is a must for developers. As you can see above, there are a ton of cases that we check for an endpoint. Differing from Google Sheets, you can immediately test the endpoints on Postman once you’ve finished development. Quite practical, don’t you think?

Making it easier

Assume you want to write software for an authentication endpoint. You’ve written the login and register logic, heck, you’ve even finished the logout logic. However, it appears that after you login, you can access the website for who knows how long. Thus, you make a user story:

“As a user,
I want to be able to automatically cease my session after an amount of time
So that no one can access my account if I forgot to logout.”

From there, you determine acceptance criterias:

“When I leave the site for 10 minutes, I want my session to end”

Now, using this as a guide, we can start TDD!

Three Simple Rules

When you think of TDD, you think about these particular set of actions. Red, Green, Refactor!

Marsner Technologies

Red:

Write a failing test. In this stage, you, as the developer, will write tests for features or functionalities that has yet to exist. Crazy innit? But no worries!
When running the tests, they are supposed to fail, indicated by the color red. This failure confirms the validity of the test and verifies that it is checking for the intended functionality.

Initial adding of basic tests, errors still exists; [RED] stage

Green:

Make the test pass. In this stage, your make your previous “red” tests, “green” by writing the minimum amount of code to satisfy the requirements of the test. When running the tests, they are supposed to now pass, represented by the color green. This stage focuses solely on making the code functional. So, don’t worry too much about optimization or best practices.

Created running tests expanded from the initial tests, all running; [GREEN] stage

Refactor:

Improve the code. Once the tests pass, you, as the developer, will make an effort to improve its performance, maintainability, and readability. Don’t forget to ensure that all the tests still passes though!. This stage is about cleaning up the code. Applying best practices without changing the functionality.

Duplicate and redundant negative tests removed to make code cleaner, still running; [REFACTOR] stage

Frameworks that might come in handy

JEST

Jest is a widely-used testing framework for JavaScript developed by Facebook. We’ve chose this framework for testing our front-end that is using Next.js for a few but important reasons:

Jest allowed us to write unit tests for React components in our Next.js app. These tests ensure that components render correctly, handle props and states properly, and interact with the DOM as expected.

  it("[Positive] - should call 'sendOTP' with correct data on register form submission", async () => {
render(
<RegisterCard
onFormChange={mockOnFormChange}
setShowOTP={mockSetShowOTP}
data={{
email: "positivetest@example.com",
password: "positivepassword",
confirmPassword: "positivepassword",
firstName: "Positive",
middleName: "Test",
lastName: "Case",
}}
sendOTP={mockSendOTP}
/>,
);

fireEvent.click(
screen.getByRole("button", {
name: /Register/i,
}),
);

await waitFor(
() => {
expect(mockSetShowOTP).toHaveBeenCalled();
},
{ timeout: 2500 },
);

await waitFor(
() => {
expect(mockSendOTP).toHaveBeenCalled();
},
{ timeout: 2500 },
);
});

Jest enables integration testing for the dynamic pages created in the pages directory. Therefore, we utilized this feature to check that pages rendered correctly, handled data fetching and client-side navigation well, and maintained the correct state.


describe('LoginModule Positive Tests', () => {
test('successful login', async () => {
const { getByLabelText, getByText } = render(< LoginModule />);

fireEvent.change(getByLabelText(/Your email/i), { target: { value: 'risa.lestari2002@gmail.com' } });
fireEvent.change(getByLabelText(/Password/i), { target: { value: 'sahskahskahnsakh' } });
fireEvent.click(getByText(/Log In/i));

await waitFor(() => expect(mockedUsedNavigate).toHaveBeenCalledWith('/'));
});
});

Lastly, Jest offers built-in support for mocking dependencies. This meant that we could isolate components, pages, and API routes from their external dependencies during testing. This might be one of the most important feature that Jest has and it sure did come in handy for our team


describe("RegisterCard Component", () => {
const mockOnFormChange = jest.fn();
const mockSetShowOTP = jest.fn();
const mockSendOTP = jest.fn();

expect(myElem).toBeInTheDocument(); // ASSERT
beforeEach(() => {
jest.clearAllMocks();
});

PyTest

PyTest is a powerful testing framework for Python that simplifies writing and executing tests. It’s widely used in the Python community due to its simplicity, flexibility, and extensive features.

Our project uses FastAPI, therefore, our utilization of PyTest proved incredibly useful for testing various components.

FastAPI allowed us to define API endpoints using Python functions. We utilized PyTest to write unit tests for our endpoints, ensuring they handle requests correctly, validate input data, and return the expected responses.

def test_register_positive(test_db):
response = client.post(
REGISTER_URL,
json = USER_REG,
)
assert response.status_code == 200
# assert response.json()["email"] == "positive@example.com"

FastAPI comes with built-in data validation features using Pydantic models that our project currently uses in defining the different objects in our application. PyTest was then used to test data validation logic, ensuring that input data is properly validated according to the specified Pydantic models.

def test_determine_streak_decision_terminate(streaks_service):
time_now = get_datetime_now_jkt()

input_value = {
"current_time": time_now,
"streak_last_updated": time_now - timedelta(hours=25), # outside of 24h
}

expected_response = "TERMINATE"

actual_response = streaks_service.determine_streak_decision(
**input_value
)

assert actual_response == expected_response

Like Jest, PyTest provides built-in support for mocking external dependencies. This allowed us to isolate components and test them in isolation.

Why TDD?

A question me and my team members often ask ourselves in the process of making the tests. While frustrating at first (it still is), the results can be very rewarding. Thus, test-driven development (TDD) offers several benefits, including:

  1. Improved Code Quality: TDD encourages writing clean, modular, and well-structured code from the start, as tests are written before the actual code.
  2. Faster Feedback Loop: TDD provides immediate feedback on code changes, allowing developers to catch and fix issues early in the development process.
  3. Better Design: TDD promotes a focus on requirements and design before implementation, leading to more thoughtful and maintainable code architecture.
  4. Reduced Debugging Time: Since TDD catches defects early, it reduces the time spent on debugging and fixing issues later in the development cycle.
  5. Increased Confidence: Having a comprehensive suite of tests ensures that developers have confidence in the correctness of their code, enabling them to make changes confidently without fear of breaking existing functionality.
  6. Documentation: TDD serves as living documentation, providing insight into the intended behavior of the code through the test cases.

Overall, TDD promotes better code quality, faster development cycles, and increased confidence (and frustration) in the process and the software being developed.

Conclusion

To wrap things up, Test-Driven Development (TDD) is a methodical way to build software by first creating tests before writing actual code. Following the red-green-refactor flow ensures that the code is thoroughly checked, works correctly, and is easy to maintain. TDD is unconventional and encourages a shift in the average developer’s mindset towards designing software based on how it will be used, resulting in stronger and more dependable applications. While TDD might seem like extra work initially (and based on experience, it sometimes is XD), it pays off by saving time and reducing errors in the long term. Trust me when I say this (or not, up to you), TDD can lead to better code quality, faster development, and increased confidence in the final product.

References

https://scalablehuman.com/2023/03/16/what-is-red-green-refactor/

--

--