Understanding Unit Testing

Muhammad Rafif Murazza
10 min readNov 8, 2022

--

I have been working in tech industry for around 6 years now. I have been introduced to unit testing since my first year of working back in early 2017 and have been trying to be consistent in practicing it since then.

Despite my experience of writing unit test, I encountered some difficulties when I had a chance in one of my previous job to initiate & start doing unit test where I need to introduce unit testing to my team members. Turns out teaching unit test is not as easy as writing one. So, this post is made in order to hone my understanding of unit testing 😅.

This post will be divided into 5 parts:

  1. Preparing Input & Expected output, & Assertion
  2. Mocking
  3. Mock Static
  4. Making Test Cases & Improving Test Coverage
  5. Common first-time mistakes (in other post)

Please note that this code will be quite technical & consist of many code examples.

Part 1: Preparing Input and Expected Output, and Assertion

By definition, unit testing test the smallest testable ‘stuff’ or unit in a software, which is a function or method within a source code. The goal of unit testing is to make sure a function when given some predefined input, it would behave or return an output as the predefined one set by the author.

In short, in unit test, we need to do is only to prepare an input and expected output, and then compare the actual output of the tested function with the one we set as expected output. This comparison method is called assertion.

Moving forward, to give more clarity we will discuss with an example use case written in Golang & Java.

Let’s say we have a UserService that has a method Validate() to validate a User struct to make sure its properties (Name & Phonenumber) are non-empty and its Phonenumber property is numeric only.

user.go
User.java

With the given use case, we would like to make a positive test case for the Validate function. In order to do that, we need to create a valid User struct then call the Validate function. Then, make sure it returns no error.

Positive Case:
Input: Valid User.
Expected Output: No Error.
Assertion: Actual Error must be nil.

positive case (Golang)
positive case (Java)

The same thing if we want to have a negative test case, test validating an invalid user. We will need to prepare a bunch of user struct that has invalid value as input, and its expected error message as output, then assert the actual returned error with the expected one.

Example Negative Case:

Negative Case 1:
Input: Invalid User (User with Empty ID).
Expected Output: Error with error message “ID cannot be empty”.
Assertion: Actual Error must be equal with expected error, or must contains error message: “ID cannot be empty”

example of negative case 1 (Golang)
example of negative case 1 (Java)

Negative Case 2:
Input: Invalid User (User with Empty Name).
Expected Output: Error with error message “name cannot be empty”.
Assertion: Actual Error must be equal with expected error, or must contains error message: “name cannot be empty”

The important note is that the input and expected output should be defined manually by the author. Meaning that it should not be derived from other function/codes because it will beat the purpose of unit testing. If we call other function in the unit test code, this extra function must be also tested to lock its behavior over time. It is going to be an endless cycle if we rely on other function to generate the input/output in our unit tests.

Part 2: Mocking

For the first part, we have covered the basic of unit tests. The basic of unit test is quite straightforward, but why most of engineers struggle in their early days when jumping into unit test? Yes, the hard part of unit test is when we put mocking & stubbing into the table.

What is Mocking? Why do we need to Mock stuff? When do wee need to Mock stuff?

Mocking is basically creating dummy objects to simulate the real object. The goal of mocking is to isolate the scope of our unit test.

The idea is to minimize the scope of our unit test as minimum as possible, because the goal of unit testing is to test the logic of our code. Other external calls, such as libraries, external services, and data source queries, inside a function should be treated only as control variable because it is supposedly more complex & costly if accessed within a unit test. Thus, if a function has an external call to other class, we should not let the test actually access them. We should replace the external processes by creating a dummy (mock) and simulate them so that we can focus on testing the logic in the tested function only.

This is where mocks become useful. Mocking & stubbing can prevent outside calls and enable us to simulate the external process by specifying how we want the them to behave when given some inputs. With mocks, we could specify the external call to return error or some predefined response.

Let’s jump to the example. We’ll add more feature in our UserService code. Firstly, we will add several layers which are service and repository in our code.

service & repository layer interface (Golang)
service & repository layer interface (Java)

Second, we will add the implementation class. Note that I use dependency injection through constructor to inject the repository class. I’ll assume that the readers already familiar with this practice. Dependency injection helps ease the mocking process. Because then, we only need to replace the actual repo class with the mocks one when constructing the serviceImpl class for unit test.

service_impl.go
serviceImpl.java

Third, this will depends on the language and framework you use. In Golang we need extra help in making the boilerplate mock class. In this case, I use Mockery to generate the mock classes. For our use case, we only need to mock our repository class.

Mockery provides the ability to easily generate mocks for Golang interfaces using the stretchr/testify/mock package. It removes the boilerplate coding required to use mocks.

While in Java & Kotlin, with the help of Mockito, initializing mock objects can be done just by using Mockito.mock(<ClassName>.class). It will return a mock object of your specified class that you could just pass around. So we could move to next step if you work with Java.

Fourth, we add mock initialization in our user_service unit test class.

Note that in line 23 [Golang], we init our mockRepo object that previously generated by mockery. Then we pass the mockRepo to construct UserService class that we want to test (line 27–29 [Golang]).

user_service_test.go (init)
userServiceTest.java (init)

Finally we are ready to start writing unit test using the mock class.

If we look back to the inside of our CreateUser function, it only consist of validation & storing to database by calling the repo service.

For the positive case, the behavior that we expect is that when we give a valid user (validUser) as input to our CreateUser(), it will pass the validation, then call repo.Persist() and return a user with ID (validUserWithID).

Positive Case — CreateUser():
Input: Valid User.
Expected Output: Valid User with ID & No Error.
Assertion: Result must equal ValidUserWithID & Actual Error must be nil.

For this case to work, we need to stub our mockRepo.Persist() so that when it is called with validUser as input it will return validUserWithID. The command can be seen in the example below at line 48 (Golang).

user_service_test.go (cont.)
Test result (Golang)
userServiceTest.java (cont.)
userServiceTest.java (cont.)
Test result (Java)

Part 3: Mock Static

We have covered topic about mocking, so we have done with the hardest part right?

Unfortunately no, we still have some topics left about mocking that I, myself, struggled with in the past and I saw my colleagues also struggled with, which is mocking static class & stubbing static functions.

This topic discussion will be quite different between Golang & Java.

In Golang, we are able to import function directly from a package to our code. While in Java, being an OOP language, we must specify static signature if a function does not belong to a class object. Despite having similar practice, how we handle it in a unit test is different.

The common example is date initialization. time.Now() in Golang and Calendar.getInstance() in Java.

As far as I know, by the time I write this, there is no way in Golang we can stub time.Now() without modifying the code to be stateful or by injecting the function time.Now() as dependency. (reference of StackOverflow discussion here)

Meanwhile, in Java, we can utilize Mockito.mockStatic() to solve it (see this tutorial on implementing mockStatic here). We’ll modify our CreateUser() function by setting current time as timestamp of the object User before it being persisted.

User.java (updated)
UserServiceImpl.java (updated)

By adding code to set User timestamp with current time, we need to mock the current time value so that every time we run the test it will return the same value. Otherwise, when the mockRepo want to simulate Persist() based on line 33 at UserServiceTest.java (cont.), it will not return VALID_USER_WITH_ID because the timestamp of the actual input will be different with the one we defined in line 33 at UserServiceTest.java (cont.).

In the screenshot below, the part that mock the date is in line 36–39. Also note that we updated the constant dummy value in line 34 & 41 as well.

To elaborate more, in line 36–39, we create a mock object of Calendar class, then we stub the static function Calendar.getInstance() to always return the calendarMock object when called. Then from the calendarMock we will stub getTime() function to return our dummy date (MOCK_DATE) value.

UserServiceImplTest.java (updated)

Part 4: Making Test Case & Improving Test Coverage

While there are still a lot of real-life cases that might need further tweaks from the discussed steps above, we have already covered what I believe are the core practice in writing unit tests.

Next discussion would be on When should we think we have wrote enough unit test? Are we writing too much cases? Or are we still not handling some edge cases?

The easy way would be to check you code test coverage. You need to make sure the test cases have covered every scenarios in your code. These scenarios are derived from the branches of conditional statements, such as if-statement, case-statement, and try-catch.

Running test with coverage on IntelliJ Idea

From our use case example, we have prepared negative case for validate() function in the case of empty name. Is there any test case that have covered the case when the name is not empty but the phoneNumber is empty? How about when the phoneNumber is not numeric? Ideally, we need to make sure we create test cases that cover every possibilities that go through every condition/branches from our if-statements within the validate() functions.

code with coverage mark (left) — coverage percentage (right)

The next part is in different post since this post already got quite long:
Common First-Time Mistakes in Writing Unit Tests

GitHub Repo for use case example used in this post can be accessed here : mrmurazza/practice-unit-test (github.com). *For now only Go & Java example there, might add more language example in the future.

This should be it. I hope I explain stuff clearly enough for the readers to understand. If you found any mistake or anything that I missed please do tell me. Any kind of feedbacks or things to discuss are very welcome.

I hope this post will be somewhat useful to anyone out there. 😁

--

--