Why do I like TDD but was skeptical about it at the beginning?

Masud Rana
8 min readNov 14, 2022

I am currently working as a software developer and using the elixir-based Phoenix framework as a technology stack. Working with elixir is fun as it's a fault-tolerant language which means we need not have to be defensive while coding and can concentrate on building the core logic quickly. How elixir achieves such kind of fault-tolerant properties can be discussed in another article.

Today, my goal is to discuss test-driven development(TDD), the environmental setup for the elixir application to effectively follow the TDD concept, and why I liked it but was skeptical about it at the beginning of its introduction.

Test-driven development (TDD) is a software development process where requirements are converted to concrete test cases before starting the actual development. This definition seems to assume that we have to develop software, and we know the requirements beforehand. But in reality, this might not be often true. In the actual software development process, we might start with a small set of requirements and build the application incrementally or agile way. And sometimes, the core product of a company might already be built, and software developers only work on solving bugs or adding custom features. So, how does TDD fits in this kind of scenario?

In my opinion, TDD is a developer's mindset, and he wants to write the core requirements and edge cases as a test before starting development to get a concrete example of the requirement. Writing test cases for the whole application beforehand might not need and each developer can start writing tests before and developing their part of the project. This is the most practical way of using TDD, in my opinion, where we do not have to wait until the completion of writing test cases.

The software development organization might have a separate test team to write unit and acceptance tests, but it's not directly related to TDD practice. Software developers follow TDD for certain benefits that I will discuss later, but they do not overlap with the software testing. Because developers can miss some edge cases while writing test cases. In short, TDD is not an alternative to software testing in general.

Let’s discuss the environmental setup needed to practice TDD while developing elixir applications.

First, we need to create a mix application, or you can create a Phoenix application. Phoenix application is also a mix application. I am adding both commands for convenience.

# for mix application without phoenix dependency
mix new tdd_practice

# for phoenix application
mix phx.new tdd_practice

In the later section of the code, I will only put snippets for mix applications without the phoenix framework.

Now go to your mix.exs file in the root directory of the project and add the dependency of mix_test_watch for dev only like below.

defmodule TddPractice.MixProject do
use Mix.Project

def project do
[
app: :tdd_practice,
version: "0.1.0",
elixir: "~> 1.13",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end

# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end

# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:mix_test_watch, "~> 1.0", [only: :dev, runtime: false]}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end

mix_test_watch library is highly recommended for test-driven development approach. This tool watches code changes and runs the test automatically. We can watch a specific test file or a specific test with it or all tests for the application. It is also possible to watch only previously failed tests. After adding the library dependency, please run the following command to get it from hex.

mix deps.get

Below I am providing some highly used commands of mix_test_watch.

# watch all tests
mix test.watch

# watch a specific test file
mix test.eatch absolute_or_relative_path_to_file

# watch a specific test case in a file
# mix test.eatch absolute_or_relative_path_to_file:line_number_of_test_start
# eg.
mix test.eatch absolute_or_relative_path_to_file:10

# run previously failed tests
mix test.watch --failed

Except for this tool, I highly recommend using the VSCode extension: Elixir Jump to Test. This tool is really helpful for navigating test files and the code. For the VSCode, there are several other recommended extensions for elixir and phoenix development. Still, I am not mentioning those here as this article is focused on TDD only.

So, with this setup, let's try a demo development with the TDD approach:

The requirement, in plain words: “Build a tool that can safely divide two numbers. The expectation is to get nil returned in case of divide by zero occurs. Numbers can be in string or numerical form. All other edge cases need to be considered.”

So, to convert these requirements into concrete test cases, let's first create a test file named safe_divide_test.exs in \test directory.

the file location of the safe_divide_test

Add the following content to the newly created test file.

defmodule SafeDivideTest do
use ExUnit.Case
end

So, we assume that we will have a module named SafeDivide, and this newly created test file is responsible for testing that module. Let's create that module as well in the lib/ directory.

SafeDivide module

Lets us also assume that this module will have a function named safe_divide that will be responsible for fulfilling our requirements. We do not know anything about this function as we haven’t written any tests. So, let's create tests for it.

According to the requirement, this function should be able to divide two numbers correctly, so if we pass 4 and 2 as arguments, I should get 2 as a return.

  test "safe_divide/2 returns the result of dividing the first argument by the second" do
assert SafeDivide.safe_divide(4, 2) == 2
end

The requirement also says that dividing by zero should return nil. So, below is the test case written for it.

  test "safe_divide/2 returns nil when the second argument is zero" do
assert SafeDivide.safe_divide(4, 0) == nil
end

Another requirement is to accept a string argument castable as a number and properly return the division result.

  test "safe_divide/2 returns the result of dividing when argument is string" do
assert SafeDivide.safe_divide("4", "2") == 2
end

And we can also write more edge cases and assumptions of receiving bad arguments and potential output for it. I am considering a return of nil ..if any of the argument is invalid.

  test "safe_divide/2 returns nil when one of the argument is invalid" do
assert SafeDivide.safe_divide("a", 2) == nil
assert SafeDivide.safe_divide(4, "b") == nil
assert SafeDivide.safe_divide("a", "b") == nil
end

For simplicity, I am not adding more tests. And it's also not good to add a massive number of overlapping tests. It's a good practice to write simple and concise tests to cover most branches with minimal tests.

So, now we know our concrete requirements and must pass these tests. Below, the full test file is provided.

defmodule SafeDivideTest do
use ExUnit.Case
alias SafeDivide

test "safe_divide/2 returns the result of dividing the first argument by the second" do
assert SafeDivide.safe_divide(4, 2) == 2
end

test "safe_divide/2 returns nil when the second argument is zero" do
assert SafeDivide.safe_divide(4, 0) == nil
end

test "safe_divide/2 returns the result of dividing when argument is string" do
assert SafeDivide.safe_divide("4", "2") == 2
end

test "safe_divide/2 returns nil when one of the argument is invalid" do
assert SafeDivide.safe_divide("a", 2) == nil
assert SafeDivide.safe_divide(4, "b") == nil
assert SafeDivide.safe_divide("a", "b") == nil
end
end

Now, mix_test_watch will come into action. Let's run mix test.watch test/safe_divide_test.exs

You will see 4 tests, 4 failures as below.

mix_test_watch in action

As we did not add the safe_divide function in the SafeDivide module, the result says it's undefined or private. So, let's add it and try to pass test1.

We can add the following code to pass test case 1.

  def safe_divide(a, b) do
2
end

But as you can see, this implementation doesn’t fulfill the intent of the test case. As the developer writes the test in TDD, he knows the intent and will not write this kind of code as an implementation. So, we might get an implementation like the one below to solve test case 1.

  def safe_divide(a, b) do
a / b
end

Ok, now, after saving the file, I can see one test passed and 3 failures.

Now, let's try to solve the divide by zero test case:

  def safe_divide(a, b) do
cond do
b == 0 -> nil
true -> a / b
end
end

And we can see now 2 tests passed.

Similarly, we can try to solve the other two test cases and end up something like this.

defmodule SafeDivide do
def safe_divide(a, b) do
cond do
b == 0 ->
nil

is_number(a) and is_number(b) ->
a / b

is_castable_number(a) && is_castable_number(b) ->
a =
Float.parse(a)
|> elem(0)

b = Float.parse(b) |> elem(0)

a / b

!is_castable_number(a) || !is_castable_number(b) ->
nil

true ->
a / b
end
end

defp is_castable_number(num) do
cond do
is_number(num) ->
true

is_binary(num) ->
Float.parse(num)
|> case do
{_, ""} -> true
_ -> false
end

true ->
false
end
end
end

Now all 4 test cases passed, although we might still miss some edge cases. This also implies that TDD is not an alternative to software testing.

Now, we get some idea about TDD in practice. So, what do I like about it?

  • TDD relieves lots of stress while development as requirements are concrete after writing the tests, and I do not need to be overwhelmed with the overall requirement. Moreover, it is easy to plan big tasks when split into small tests.
  • Make code less error-prone.
  • I believe TDD is arguably a faster process for developing reliable software.
  • Tests are good documentation of functional use cases.
  • With TDD, it is possible to trace cases that were considered while development, and the new cases can be quickly checked by just writing additional tests and also easy to find out the missing case.

Although I like TDD very much and use it daily, I was initially skeptical about it. Because I thought TDD was an alternative to software testing. And I could not find any point in writing test cases by the developer. Because developers might write tests according to their imagination and develop code based on that. And might also miss key use cases.

--

--

Masud Rana
0 Followers

Software Developer at GlobalReader