How to Write Unit Testing

3 min

Using code to test code is harder than running a program manually. Suppose you have finished some CRUD code and want to verify that the code is running correctly. Your code might look like this:

func foo(user, post, arrayOfComments)error{
    createUser(user)
    createPost(user.id, post)
    createComments(post.id, arrayOfComments)
}

How do we test the foo function?

To ensure the foo function works correctly, we can build and run the program, and then trigger the foo function. Before running it, we should have a test environment with the same database connection and imported dependencies as the production environment. Additionally, we must construct the relationships between the user, post, and comment.

The foo function is not a stand-alone function; it calls other create functions. After calling foo, we need to check that the database records have been created successfully. Our unit test might look like this:

func testFoo(){
    err := foo(user, post, arrayOfComments)//call foo
    assert(err==nil)
    ensure_created(user)
    ensure_created(post)
    ensure_created(arrayOfComments)
}

Does it make sense?

No.

The effect on the database is not shown in the output of foo. Our test case should not check the database. It’s not the responsibility of foo. We need to check the result of foo and write additional test cases for the other create functions.

func testFoo(){
    err := foo(user, post, arrayOfComments)//call foo
    assert(err==nil)
}

func TestUserCreate(){
    createUser(user)
    ensure_created(user)
}
func TestPostCreate(){
    createPost(post)
    ensure_created(post)
}
func TestCreateComments(){
    createComments(post.id, arrayOfComments)
    ensure_created(arrayOfComments)
}

Setting up a test environment is not easy because the code in our development might be:

  • Written by others whom you don’t know and can’t talk with face-to-face.
  • Filled with messes and traps that you can’t refactor immediately.
  • Written in a way you don’t understand.
  • Part of a codebase with many dependencies, so you must build and run all the code.

In these situations, we don’t have many choices. We might as well write simple calling test code:

func testFoo(){
    err := foo(user, post, arrayOfComments)//call foo
    assert(err==nil)
}

Does it has problems?

No.

Is it perfect?

No.

But it’s better than having no test at all. The next time you want to change the code or test it again, you can use this code repeatedly. Your code is now running independently. Even if we are just calling foo, it’s worth writing a simple test case. It means we have a small running environment for our code. We don’t need to build and run all the code. This is the first step in constructing the unit test environment.

Building and running code by hand is easy, but it’s just one-time work. Using unit tests, you can create fake data to run a function thousands of times with various data types easily. Manual tests run only at that moment, and we must construct the same test case again the next time. This does not follow the DRY (Don’t Repeat Yourself) principle.

func testFoo(){
    for i:=0; i< 1000 i++{ //how to use your hand running a case 1000 times easily?
        err := foo(user, post, arrayOfComments)
        assert(err==nil)
    }
}

When I used to test my API with Postman, I always wanted the unit tests to automatically test my API and clean the database after all tests had passed. This thought was wrong; testing the calling chain with unit tests is not appropriate. I should find other tools to automate the scheduling. Unit tests should be small units, not for function pipelines. In addition, you should not maintain the unit test running order. You should keep unit tests simple and small.

If your code is coupled, divide it into several modules or functions to write the unit tests easily. If you can’t decouple your code, just write the testing code for core features. The automatic running of unit tests is not as important as reusing the testing code. That’s why I don’t like TDD (test-driven development). Writing code is not hard and does not need too much time. Most of our time is spent on understanding requirements and debugging code. TDD assumes you know what you are doing and write the test before your production code. Sometimes you don’t fully understand the final requirement. For this reason, your code should be easy to change (ETC). Your testing code is not experimental; it is like production code and should be readable, maintainable, and flexible. It is part of your production.

When I tried to solve LeetCode problems, it gave me feedback on the power of unit tests and forced me to write code correctly. Runnable code is easy to write, but correct code is hard to write. I found that if you only think about running your code, it will not be solid and abstract. To pass all of the unit tests in LeetCode, I must think about every possible execution branch. It’s a good way to improve my coding skills. However, LeetCode problems have explicit input/output. When building software, it’s rare to see explicit input/output. Client requirements are hard to understand completely and might change tomorrow. Our hardware, network, and other components might fail at any time. Running our code correctly today and rerunning it tomorrow is enough.


Note: This post was originally published on liyafu.com (One of our makers' personal blog)


Nowadays, we spend most of my time building softwares. This means less time writing. Building softwares has become my default way of online expression. Currently, we are working on Slippod, a privacy-first desktop note-taking app and TextPixie, a tool to transform text including translation and extraction.