The foundation of your test suite will be made up of unit tests. Your unit tests make sure that a certain unit (your subject under test) of your codebase works as intended. Unit tests have the narrowest scope of all the tests in your test suite. The number of unit tests in your test suite will largely outnumber any other type of test.

https://martinfowler.com/articles/practical-test-pyramid.html#UnitTests

Pros

Cons

Fast

Unit test alone doesn't provide guarantees about the behavior of the system

Stable and reliable

Independent and isolate failures

Easy to write and maintenance

Boundary

Base on our current architecture, we have all of our business logic inside Service Layer.

So the boundary of Unit Test is the classes inside service layer.

Where to put the unit tests

under the folder /test

Objectives

  • TDD

  • protection against regressions

  • resistance to refactoring

  • fast feedback

  • maintainability

Guidelines

  • Do not ignore/comment out the failing tests

    • fix it if some business logic get broken

    • delete it if the test is not required anymore

  • Test the smallest unit of code, each test case cover one business logic(condition or branch)

  • Must have Strong assertions, avoid assertion-free testing

  • Use test doubles to mock dependencies (recommend to use https://mockk.io/#annotations )

  • Keep unit test simple and straightforward

    • No try catch (use assertThrows to assert method which throws exception)

    • No if else

    • No loops (use ParameterizedTest )

  • Try to re-use test data How to define a Fixture

  • Must have good code coverage (90% above), check test coverage report(build/jacocoReport/index.html) if you are not confident with code coverage

How to Write Unit Test

Create Test Class

  • One test class per production class

  • Test class should have same package name with production class but under test folder

  • Test class should be named XXXXTest (XXXX is the production class under testing)

  • Create test class for implementation instead of interface

Press Command + Shift + T in the class file under testing, click ok to create test file

Inject Mock Dependencies

  • Mock all dependencies, prefer to use https://mockk.io/#annotations

  • Prefer to annotate @ExtendWith(MockKExtension::class) on unit test (@MicronautTest starts a micronaut server which make test 10x slower than pure unit test)

@ExtendWith(MockKExtension::class)
class CustomerSupportServiceImplTest {
    @InjectMockKs
    private lateinit var customerSupportService: CustomerSupportServiceImpl
    
    @MockK
    private lateinit var customerManagerApiClient: CustomerManagerApiClient
    
    @MockK
    private lateinit var auditLogProducer: AuditLogProducer
}

Test Business Logic

  • Follow a basic pattern “Given, When, Then“ https://martinfowler.com/bliki/GivenWhenThen.html

  • Test the public method of the class

  • Use a sentence to describe what behavior is under testing ( for example method name - should return null with more than one customer found ) and test one condition per test. This helps you to keep your tests short and easy to understand.

  • Test should be simple and straightforward

    • No try catch (use assertThrows to assert method which throws exception)

    • No if else

    • No loops (use ParameterizedTest )

  • Put test data in companion object at end of test file, or create a Fixture file to put the data sharing in multiple tests

    @Test
    fun `find customer - should return the customer`() {
        every { customerManagerApiClient.findCustomer(phoneNumber.countryCode, phoneNumber.number, true) } returns customerDtoResponse

        val customer = customerSupportService.findCustomer(customerFullPhoneNumber)

        assertTrue(customer?.phoneNumber?.countryCode == phoneNumber.countryCode)
        assertTrue(customer?.phoneNumber?.phoneNumber == phoneNumber.number)
        verify(exactly = 1) {
            customerManagerApiClient.findCustomer(phoneNumber.countryCode, phoneNumber.number, true)
            auditLogProducer.send(null, customerDtoResponse.results[0].customer.id, any())
        }
    }
    
    companion object {
        private val ACCOUNT_ID = UUID.randomUUID()
        private val phoneNumber = CustomerDtoPhoneNumber("12", "0123456789")
        private val customerFullPhoneNumber = phoneNumber.countryCode + phoneNumber.number
        private val customer = CustomerDto(ACCOUNT_ID, true, null, null, null).phoneNumber(phoneNumber)
        private val customerDtoResponse = CustomerSearchResultDto(listOf(CustomerSnapshotDto(customer)), 1, 0, 10)
        private val emptyCustomerDtoResponse = CustomerSearchResultDto(emptyList(), 0, 0, 10)
        private val multipleCustomersDtoResponse =
            CustomerSearchResultDto(listOf(CustomerSnapshotDto(customer), CustomerSnapshotDto(customer)), 2, 0, 10)
    }

Understand Test Coverage

Test coverage report can help us to identify uncovered/untested business logic.

From the below coverage file, there are some branches not fully covered line 49 highlighted with red color and line 26, 42, 43 highlighted with yellow color , that’s why coverage report show 88% of line coverage and 75% branch coverage.

In this case you need to add test case to covered these branches.

Refactor

https://refactoring.com/

Once we have all business logic covered by unit test, we are confident to refactor our code without concerns of breaking any features.