Tuesday, December 11, 2018

Spring: how can I test my bean?

0. Intro

Testing is very important part of development. In this post I want to show most popular options of testing Spring application.

1. Test application

I want to create the simulation of permission checking logic: main idea is to check if the user has access to group. This operation will be performed by PermissionService. But it also has dependency to service UserGroupService which can return list of user groups .

1.1. Project structure

I have two domain classes: User and Group, two interfaces/services: Permission and UserGroup, config class and main runner: App class.



1.2. Gradle build

I'm using lombok to reduce some boilerplate code, so lombok plugin is being applied. Also, and beside spring  dependencies I have mockito for testing.


plugins {
    id 'java'    id 'io.franzbecker.gradle-lombok' version '1.8'    id 'application'}

group 'com.demien'version '1.0-SNAPSHOT'
sourceCompatibility = 1.8
repositories {
    mavenCentral()
}

dependencies {
    compile group: 'org.springframework', name: 'spring-core', version: '5.1.3.RELEASE'    compile group: 'org.springframework', name: 'spring-context', version: '5.1.3.RELEASE'    compile group: 'org.slf4j', name: 'slf4j-simple', version: '1.7.25'

    testCompile group: 'junit', name: 'junit', version: '4.12'    testCompile group: 'org.springframework', name: 'spring-test', version: '5.1.3.RELEASE'    testCompile group: 'org.mockito', name: 'mockito-all', version: '1.10.19'}

mainClassName = 'com.demien.springtest.App'

1.3. Services

For services I'm creating interface and implementation.

UserGroupService should return for provided user list of assigned  groups:

package com.demien.springtest.service;
import com.demien.springtest.domain.Group;import com.demien.springtest.domain.User;import java.util.List;
public interface UserGroupService {

    List<Group> getUserGroups(User user);
}


Of course in real life it should call DB or another service to get this list of groups, but for this example I'm just returning two "hardcoded" groups: "first" and "second". 

package com.demien.springtest.service;
import com.demien.springtest.domain.Group;import com.demien.springtest.domain.User;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.stereotype.Repository;
import java.util.Arrays;import java.util.List;
@Slf4j@Repository@Qualifier("UserGroupService-main")
public class UserGroupServiceImpl implements UserGroupService {

    @Override    public List<Group> getUserGroups(User user) {
      log.info("It should be DB call here");        return Arrays.asList(
                new Group("first"),                new Group("second")
        );    }
}


Permission service should check if provided user has access to provided group.

package com.demien.springtest.service;
import com.demien.springtest.domain.User;
public interface PermissionService {
    boolean hasAccess(User user, String groupName);}

For this, service is calling UserGroupService to get list of groups assigned to user and checks if provided group exists in this list.

package com.demien.springtest.service;
import com.demien.springtest.domain.User;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.stereotype.Service;
@Slf4j@Service@Qualifier("PermissionService-main")
public class PermissionServiceImpl implements PermissionService {

    @Autowired    @Qualifier("UserGroupService-main")
    private UserGroupService userGroupService;
    @Override    public boolean hasAccess(User user, String groupName) {
        log.info("calling userGroupService");        return userGroupService.getUserGroups(user).stream().anyMatch(group -> group.getName().equals(groupName));    }
}

1.4 Other stuff. 

Domain objects are very simple:
Group:
package com.demien.springtest.domain;
import lombok.AllArgsConstructor;import lombok.Getter;
@Getter@AllArgsConstructorpublic class Group {

    String name;}


User:
package com.demien.springtest.domain;
public class User {
}


Config class is just specifying package to scan for spring annotations:
package com.demien.springtest.config;
import org.springframework.context.annotation.ComponentScan;import org.springframework.context.annotation.Configuration;
@Configuration@ComponentScan(basePackages =  {"com.demien.springtest.service"})
public class MainConfig {

}

Main class: little bit more interesting: we have to start spring context get our PermissionService and check if user has access to group:

package com.demien.springtest;
import com.demien.springtest.config.MainConfig;import com.demien.springtest.domain.User;import com.demien.springtest.service.PermissionService;import lombok.extern.slf4j.Slf4j;import org.springframework.context.ApplicationContext;import org.springframework.context.annotation.AnnotationConfigApplicationContext;
@Slf4jpublic class App {
    public static void main(String[] args) {
        final User dummyUser = new User();        ApplicationContext context = new AnnotationConfigApplicationContext(MainConfig.class);        PermissionService permissionService =  context.getBean(PermissionService.class);        log.info("Permission result: for [admin] group: " +  permissionService.hasAccess(dummyUser, "admin"));        log.info("Permission result: for [first] group: " +  permissionService.hasAccess(dummyUser, "first"));    }
}

1.4. Execution

As expected, PermissionService calls userGroupService, which actually has to call some DB, and our user has access to group "first" and don't have access to group "admin" (we hardcoded "first" and "second" in userGroupService)

[main] INFO com.demien.springtest.service.PermissionServiceImpl - calling userGroupService
[main] INFO com.demien.springtest.service.UserGroupServiceImpl - It should be DB call here
[main] INFO com.demien.springtest.App - Permission result: for [admin] group: false

[main] INFO com.demien.springtest.service.PermissionServiceImpl - calling userGroupService
[main] INFO com.demien.springtest.service.UserGroupServiceImpl - It should be DB call here
[main] INFO com.demien.springtest.App - Permission result: for [first] group: true



2. Testing 

Application seems to be working, but how can we create some unit tests for it?

2.1. Spring test

Most straightforward approach here  - just run our main spring context, execute hasAccess mechod and check the results:


package com.demien.springtest;
import com.demien.springtest.config.MainConfig;import com.demien.springtest.domain.User;import com.demien.springtest.service.PermissionService;import org.junit.Test;import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.test.context.ContextConfiguration;import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import static org.junit.Assert.*;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = MainConfig.class)
public class PermissionServiceSpringTest {

    final User dummyUser = new User();
    @Autowired    @Qualifier("PermissionService-main")
    private PermissionService permissionService;

    @Test    public void calculateSum() {
        assertTrue(permissionService.hasAccess(dummyUser, "first"));        assertFalse(permissionService.hasAccess(dummyUser, "zero"));    }
}

Looks good, but the problem here: we have to start our main spring context, what if it has 100 beans? And anyway, we just want to test PermissionService but it's calling UserGroupService inside - so it's not a unit test, but integration test!

2.2 Spring stub test

Ok, we understand now that it's not a good idea to run the whole our main spring context, so let's create a test context with "stub" implementation of UserGroupService.

package com.demien.springtest;
import com.demien.springtest.domain.Group;import com.demien.springtest.domain.User;import com.demien.springtest.service.PermissionService;import com.demien.springtest.service.PermissionServiceImpl;import com.demien.springtest.service.UserGroupService;import org.junit.Test;import org.junit.runner.RunWith;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.test.context.ContextConfiguration;import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.Arrays;import java.util.List;
import static org.junit.Assert.assertFalse;import static org.junit.Assert.assertTrue;
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = PermissionServiceStubTest.TestConfig.class)
public class PermissionServiceStubTest {

    @Configuration    static class TestConfig {

        @Bean        @Qualifier("UserGroupService-main")
        public UserGroupService testUserGroupService() {
            return new UserGroupServiceTestImpl();        }

        @Bean        @Qualifier("PermissionService-test")
        public PermissionService testPermissionService() {
            return new PermissionServiceImpl();        }

    }

    static class UserGroupServiceTestImpl implements UserGroupService {

        @Override        public List<Group> getUserGroups(User user) {
            return Arrays.asList(
                    new Group("stubGroup1"), new Group("stubGroup2")
            );        }
    }

    final User dummyUser = new User();
    @Autowired    @Qualifier("PermissionService-test")
    private PermissionService permissionService;

    @Test    public void calculateSum() {
        assertTrue(permissionService.hasAccess(dummyUser, "stubGroup2"));        assertFalse(permissionService.hasAccess(dummyUser, "stubGroup5"));    }

}

It's much better now! We're not running our main context - just test context with needed beans. And real beans can be replaced with "stub" (UserGroupServiceTestImpl) versions!

2.3 Mock test

The problem with previous approach is: looks like for every bean, we will have to create an additional test context, and create a stub implementation of every dependency. Can we make it more simple? Sure we can: using mocks!

package com.demien.springtest;
import com.demien.springtest.domain.Group;import com.demien.springtest.domain.User;import com.demien.springtest.service.PermissionService;import com.demien.springtest.service.PermissionServiceImpl;import com.demien.springtest.service.UserGroupService;import org.junit.Assert;import org.junit.Before;import org.junit.Test;import org.junit.runner.RunWith;import org.mockito.BDDMockito;import org.mockito.InjectMocks;import org.mockito.Matchers;import org.mockito.Mock;import org.mockito.runners.MockitoJUnitRunner;
import java.util.Arrays;
@RunWith(MockitoJUnitRunner.class)
public class PermissionServiceMockTest {

    @Mock    private UserGroupService userGroupService;
    @InjectMocks    private PermissionService permissionService = new PermissionServiceImpl();
    final User dummyUser = new User();
    @Before    public void init() {
        BDDMockito.given(userGroupService.getUserGroups(Matchers.any(User.class))).willReturn(
                Arrays.asList(new Group("mockGroup1"), new Group("mockGroup2"), new Group("mockGroup3") )
        );    }

    @Test    public void test() {
        Assert.assertTrue(permissionService.hasAccess(dummyUser, "mockGroup2"));        Assert.assertFalse(permissionService.hasAccess(dummyUser, "newGroup"));    }

}


Now don't have to start spring context at all! We just have to define behavior of our mock of UserGroupService and run the test! Excellent ! But can it be better?

2.4. Refactoring

Let's take a closer look now. Actually, the main functionality we want to test is here:
userGroupService.getUserGroups(user).stream().anyMatch(group -> group.getName().equals(groupName));

Let's now try to split it into 2 functions:

public boolean contains(List<Group> groups, String groupName) {
    return groups.stream().anyMatch(group -> group.getName().equals(groupName));}

@Overridepublic boolean hasAccess(User user, String groupName) {
    log.info("calling userGroupService");    return contains(userGroupService.getUserGroups(user), groupName);}


Now our business logic isolated from userGroupService in method "contains" . And we can easily test with method using just JUnit without any mocks/stubs/spring contexts!

@Testpublic void containsTest() {
    PermissionServiceImplRefactored refactored = new PermissionServiceImplRefactored();    List<Group> testGroups = Arrays.asList(
            new Group("first"),            new Group("second")
    );    Assert.assertTrue(refactored.contains(testGroups, "first"));    Assert.assertFalse(refactored.contains(testGroups, "trash"));}


2.5. The end.

As always, simplest way is the best way! And remember, if you're starting spring context - it's not a unit test!  Full source code can be downloaded from here