How can good Unit Tests save your Dev’s well-being?

Read Time:8 Minute, 15 Second

Imagine you have to implement some business functionality to the application you’re responsible for. Sounds familiar? It may be just a simple thing like adding timestamp with date zone offset to the db entity. What should be done after a successful implementation of aforementioned feature? Unit Tests should be written, of course.

A common mistake, made by beginners especially (by experienced devs as well, of course), is writing unit tests that test methods and internal logic of the classes. It covers implementation details instead of core business features. It may cause us to miss some not-so-obvious edge cases because we try to write tests that are aligned to our implementation, not to be compliant to the user story or specification. A well written specification (tests in this case) helps us to fight that problem.

BDD Behaviour Driven Development has been brought up to life to save us from that kind of unpleasant results. It puts an emphasis on testing real functionalities before testing the dry code of ours (details of implementation). Here is anexample visualizing this error-generating peril.

This app is a little service (could be a part of some micro-service system) which is responsible for receiving events (eg. from service bus) and create or modify user data. Sounds simple but beware, there’s always a catch.

Project structure looks like this. It’s created in Gradle. Click here to find out how to manage Gradle projects.

Figure 1. Project structure visualized by IntelliJ

build.gradle script contains info about dependencies and other project properties.

build.gradle (snippet)

(...)
dependencies {
    compile 'org.mockito:mockito-core:2.21.0'
    compile 'com.fasterxml.jackson.core:jackson-databind:2.9.8'
    
    compileOnly 'org.projectlombok:lombok'
    
    implementation 'org.slf4j:slf4j-api:1.7.30'

    annotationProcessor 'org.projectlombok:lombok'
    
    testImplementation 'junit:junit'
}
(...)

Now I am going to explain briefly a responsibility of each class.

Model classes hold information about our domain. The first one is entity-like class that contains info about our AppUser. That’s pretty straightforward.

AppUser.class

@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AppUser {

    private Long id;
    private String name;
    private List<String> assignedApps;

    private OffsetDateTime userLocalCreatedAt;
    private OffsetDateTime userLocalUpdatedAt;

    public AppUser(Long id, String name, List<String> assignedApps, OffsetDateTime timestamp) {
        this.id = id;
        this.name = name;
        this.assignedApps = assignedApps;
        this.userLocalCreatedAt = timestamp;
        this.userLocalUpdatedAt = timestamp;
    }

}

Another model class hold data for events received from outside (eg. aforementioned bus).

AppUserChangeEvent.class

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Setter
@Builder
public class AppUserChangeEvent {
    private Long id;
    private String name;
    private List<String> assignedApps;
    private OffsetDateTime timestamp;
}

AdminListener class is a pseudo message listener. I will imitate receive method invocation just like there was some queue/bus attached to it. It is responsible for intercepting event, parsing it using ObjectMapper and sending to UserAdminProcessor for further processing.

AdminListener.class

public class AdminListener {

    private final UserAdminProcessor userAdminProcessor;
    private final ObjectMapper objectMapper;

    public AdminListener(UserAdminProcessor processor, ObjectMapper objectMapper) {
        this.userAdminProcessor = processor;
        this.objectMapper = objectMapper;
    }

    @SneakyThrows
    public void receive(String message) {
        try {
            var appUser = objectMapper.readValue(message, AppUserChangeEvent.class);
            userAdminProcessor.process(appUser);

        } catch (IOException exc) {
            System.out.println("Cannot read message");
            throw exc;
        }
    }
}

UserAdminProcessor contains business logic code which ‘decides’ what to do with the event it has received. AppUser can be either modified or created, depending on its existence in the database.

UserAdminProcessor.class

public class UserAdminProcessor {

    private final InMemoryAppUserRepo repository;

    public UserAdminProcessor(InMemoryAppUserRepo repository) {
        this.repository = repository;
    }

    void process(AppUserChangeEvent event) {
        var user = repository.findUser(event.getId());

        if (user != null) {
            modifyUser(event, user);

        } else {
            createUser(event);
        }
    }

    private void modifyUser(AppUserChangeEvent event, AppUser user) {
        user.getAssignedApps().addAll(event.getAssignedApps());
        user.setName(event.getName());
        user.setUserLocalUpdatedAt(event.getTimestamp());

        repository.save(user);

        System.out.println("## User " + user.getId() + " has been modified.");
    }

    private void createUser(AppUserChangeEvent event) {
        var user = new AppUser(event.getId(), event.getName(), event.getAssignedApps(), event.getTimestamp());

        repository.save(user);

        System.out.println("## User " + user.getId() + " has been modified.");

    }


}

The pseudo-repository InMemoryAppUserRepo holds a standards HashMap and some convenience methods for saving and retrieving objects from the map and therefore imitates real db repository which we know very well from Spring Data or DAO pattern.

InMemoryAppUserRepo.class

public class InMemoryAppUserRepo {

    private final Map<Long, AppUser> store = new HashMap<>();

    public void save(AppUser entity) {
        putOrReplace(entity);
        System.out.println("### Entity '" + entity + "' saved!");
    }

    public AppUser findUser(Long id) {
        return store.getOrDefault(id, null);
    }

    private void putOrReplace(AppUser entity) {
        if (findUser(entity.getId()) == null) {
            store.put(entity.getId(), entity);
        } else {
            store.replace(entity.getId(), entity);
        }
    }
}

That’t it for the classes description. How does the data flow works and look like though? The figure should answer that question.

Now let’s run our unit tests!

I am using junit unit test framework to run this thing up.

Common and not-so-correct ‘tradition’ is testing each class separately and treating each method as a ‘unit’ in question. As I mentioned in the introduction, it may cause us to miss some crucial bugs. In the test class below I’m presenting such an example. Test are passing smoothly. But are we sure our code is really correct?

NotEntirelyCorrectUnitTesting.class

@RunWith(MockitoJUnitRunner.class)
public class NotEntirelyCorrectUnitTestingTest {


    @Mock
    private InMemoryAppUserRepo repo;

    @InjectMocks
    private UserAdminProcessor processor;

    @Captor
    private ArgumentCaptor<AppUser> appUserArgumentCaptor;


    @Test
    public void userPropertiesAreSuccessfullyUpdated() {
        given(repo.findUser(9L))
                .willReturn(AppUser.builder()
                        .id(9L)
                        .name("TEST_USER")
                        .assignedApps(new ArrayList<>(Arrays.asList("GameFactor", "AdobeXD")))
                        .userLocalCreatedAt(OffsetDateTime.parse("2020-08-22T11:26:09.00023+01:00"))
                        .userLocalUpdatedAt(OffsetDateTime.parse("2020-09-23T23:21:34.45292+01:00"))
                        .build());


        //when listener receives a change event
        processor.process(AppUserChangeEvent.builder()
                .id(9L)
                .name("TEST_USER")
                .assignedApps(Arrays.asList("Notepad", "vi"))
                .timestamp(OffsetDateTime.parse("2020-09-28T18:21:34.45292+01:00"))
                .build());


        //then a correctly modified entity should be passed to the repository to be saved
        then(repo)
                .should()
                .save(appUserArgumentCaptor.capture());

        assertThat(appUserArgumentCaptor.getAllValues())
                .hasSize(1)
                .extracting(
                        AppUser::getId,
                        AppUser::getName,
                        user -> user.getAssignedApps().size(),
                        AppUser::getUserLocalCreatedAt,
                        AppUser::getUserLocalUpdatedAt
                )
                .containsExactly(
                        tuple(
                                9L,
                                "TEST_USER",
                                4,
                                OffsetDateTime.parse("2020-08-22T11:26:09.00023+01:00"),
                                OffsetDateTime.parse("2020-09-28T18:21:34.45292+01:00")

                        )
                );


    }

    @Test
    public void userIsSuccessfullyCreated() {
        given(repo.findUser(1L))
                .willReturn(null);


        //when listener receives a change event
        processor.process(AppUserChangeEvent.builder()
                .id(1L)
                .name("TEST_USER2")
                .assignedApps(Arrays.asList("Photoshop", "Eclipse", "Calc"))
                .timestamp(OffsetDateTime.parse("2020-09-22T18:29:39.23023+01:00"))
                .build());


        //then a correctly modified entity should be passed to the repository to be saved
        then(repo)
                .should()
                .save(appUserArgumentCaptor.capture());

        assertThat(appUserArgumentCaptor.getAllValues())
                .hasSize(1)
                .extracting(
                        AppUser::getId,
                        AppUser::getName,
                        AppUser::getAssignedApps,
                        AppUser::getUserLocalCreatedAt,
                        AppUser::getUserLocalUpdatedAt
                )
                .containsExactly(
                        tuple(
                                1L,
                                "TEST_USER2",
                                Arrays.asList("Photoshop", "Eclipse", "Calc"),
                                OffsetDateTime.parse("2020-09-22T18:29:39.23023+01:00"),
                                OffsetDateTime.parse("2020-09-22T18:29:39.23023+01:00")
                        )
                );
    }
}

So how should it be?

UserAdministration.class

@RunWith(MockitoJUnitRunner.class)
public class UserAdministrationTest {


    private AdminListener listener;

    @Mock
    private InMemoryAppUserRepo repo;

    @InjectMocks
    private UserAdminProcessor processor;

    @Captor
    private ArgumentCaptor<AppUser> appUserArgumentCaptor;

    @Before
    public void setup() {
        listener = new AdminListener(processor, new ObjectMapper()
                .findAndRegisterModules());
    }

    @Test
    public void userPropertiesAreSuccessfullyUpdated() {
        given(repo.findUser(9L))
                .willReturn(AppUser.builder()
                        .id(9L)
                        .name("TEST_USER")
                        .assignedApps(new ArrayList<>(Arrays.asList("GameFactor", "AdobeXD")))
                        .userLocalCreatedAt(OffsetDateTime.parse("2020-08-22T11:26:09.00023+01:00"))
                        .userLocalUpdatedAt(OffsetDateTime.parse("2020-09-21T18:21:34.45292+01:00"))
                        .build());


        //when listener receives a change event
        listener.receive("{" +
                "  \"id\": \"9\"," +
                "  \"name\": \"TEST_USER\"," +
                "  \"assignedApps\": [" +
                "    \"Notepad\"," +
                "    \"MediaCoder\"" +
                "  ]," +
                "  \"timestamp\": \"2020-10-09T12:56:09.90023+01:00\"" +
                "}");


        //then a correctly modified entity should be passed to the repository to be saved
        then(repo)
                .should()
                .save(appUserArgumentCaptor.capture());

        assertThat(appUserArgumentCaptor.getAllValues())
                .hasSize(1)
                .extracting(
                        AppUser::getId,
                        AppUser::getName,
                        s -> s.getAssignedApps().size(),
                        AppUser::getUserLocalCreatedAt,
                        AppUser::getUserLocalUpdatedAt
                )
                .containsExactly(
                        tuple(
                                9L,
                                "TEST_USER",
                                4,
                                OffsetDateTime.parse("2020-08-22T11:26:09.00023+01:00"),
                                OffsetDateTime.parse("2020-10-09T12:56:09.90023+01:00")

                        )
                );
    }

    @Test
    public void userIsSuccessfullyCreated() {
        given(repo.findUser(1L))
                .willReturn(null);


        //when listener receives a change event
        listener.receive("{" +
                "  \"id\": \"1\"," +
                "  \"name\": \"TEST_USER2\"," +
                "  \"assignedApps\": [" +
                "    \"Photoshop\"," +
                "    \"Eclipse\"," +
                "    \"Calc\"" +
                "  ]," +
                "  \"timestamp\": \"2020-09-22T18:29:39.23023+01:00\"" +
                "}");


        //then a correctly modified entity should be passed to the repository to be saved
        then(repo)
                .should()
                .save(appUserArgumentCaptor.capture());

        assertThat(appUserArgumentCaptor.getAllValues())
                .hasSize(1)
                .extracting(
                        AppUser::getId,
                        AppUser::getName,
                        AppUser::getAssignedApps,
                        AppUser::getUserLocalCreatedAt,
                        AppUser::getUserLocalUpdatedAt
                )
                .containsExactly(
                        tuple(
                                1L,
                                "TEST_USER2",
                                Arrays.asList("Photoshop", "Eclipse", "Calc"),
                                OffsetDateTime.parse("2020-09-22T18:29:39.23023+01:00"),
                                OffsetDateTime.parse("2020-09-22T18:29:39.23023+01:00")
                        )
                );
    }
}

As we can see I am testing an entire flow for two separate cases: there’s a user already so modify properties and the other: there’s no user so create one. But in this case the test cases don’t pass. Why?

Take a look at the timestamps that re send as a message to the listener. It is parsed by the ObjectMapper which converts the date-time to be adjusted to local server time (eg. 11:09:00.33+01:00 is converted to 10:09:00.33Z – UTC time). It shouldn’t happen because we’re using OffsetDateTime and we want to have the exact date that has been sent to our service – that’s why the event class contains timestamp field at all. We need user’s local time with relative to us (our server), not UTC for that user. That kind of issue could be easily detected by the feature driven tests. So all we need to do is to change ObjectMapper configuration so it doesn’t adjust any temporal values.

   @Before
    public void setup() {
        listener = new AdminListener(processor, new ObjectMapper()
                .configure(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE, false)
                .findAndRegisterModules());
    }

Now all the tests pass and we have succesfully detected a potentially hamrful bug.

Full code is available at my github repo https://github.com/rocketzki/bdd-example

I have showed you a case study concerning the importance of functionality testing. Additionally, you did have a chance to code a simple micro-service which could be serving a real business case (after adding a real framework support of course :)).

Hope you liked it.

In case of any questions and/or remarks don’t hesitate commenting or reaching me out!

Happy
Happy
100 %
Sad
Sad
0 %
Excited
Excited
0 %
Sleepy
Sleepy
0 %
Angry
Angry
0 %
Surprise
Surprise
0 %
0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x