Когда я писал статьи по JUnit, мой коллега спросил меня, а могу ли я привести пример использования продвинутого функционала JUnit, например параметризованных тестов, в дикой природе его коде, c использованием mock объектов, внедрения зависимостей итд. Пришлось показывать 🙂
Я не могу поделиться кодом проекта моего коллеги, равно как и примером теста, но полагаю, что аналогичный по содержанию, хотя и несколько надуманный пример теста с использованием всего и сразу будет полезен многим.
Подготовка
Нам понадобятся пустой maven проект с JUnit, Hamcrest и EasyMock, в проекте так же будет незримо присутствовать Spring — в зависимостях его не будет, но мы предполагаем, что приложение написано с его помощью 🙂
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <junit.version>4.12</junit.version> <hamcrest.version>1.3</hamcrest.version> <easymock.version>3.3.1</easymock.version> </properties> <dependencies> <dependency> <groupId>javax</groupId> <artifactId>javaee-api</artifactId> <version>7.0</version> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>${junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <version>${hamcrest.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.easymock</groupId> <artifactId>easymock</artifactId> <version>${easymock.version}</version> </dependency> </dependencies> |
В качестве тестируемого класса предположим, что мы написали собственный механизм авторизации, который выполняет три проверки:
- Проверяет, что присланный пользователем авторизационный токен верный
- Проверяет, что присланный пользователем токен приложения верный
- Проверяет, что пользователь не превышает ограничение на количество запросов
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | /** * Sample user object. */ public class User { } /** * User authentication token support service. */ public interface TokenService { /** * Check, if supplied token is valid * and returns related User object. * @param token Token string to validate. * @return Related user object or null if token is not valid. */ User validateToken(String token); /** * Extracts application id part from the token * @param token Token string to process * @return ApplicationId string */ String getApplicationId(String token); } /** * Provides application registration facilities. */ public interface ApplicationRegistry { /** * Checks, if application id is valid. * @param applicationId Application id to check. * @return check result. */ Boolean validateApplicationId(String applicationId); } /** * Meters rate of users requests per user. * Provides mechanisms to check, whether user * are overusing their request limit or not. */ public interface RequestLimitingService { /** * Checks, if user is overusing his limit * with specific app or not. * @param user User to check. * @param applicationId Id of request application. * @return result check. True is for positive answer. */ Boolean checkLimit(User user, String applicationId); /** * Some users are allowed to overuse resources, * so we have to check it. * @param user User to check. * @return result check. True is for positive answer. */ Boolean checkExtendedLimit(User user); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | @Named public class AuthorizationService { private final static Logger log = Logger.getLogger(AuthorizationService.class.getName()); @Named private TokenService tokenService; @Named private ApplicationRegistry registry; @Named private RequestLimitingService limitingService; public final Boolean authorize(final String token) { User user = tokenService.validateToken(token); if (user == null) { return false; } String applicationId = tokenService.getApplicationId(token); if (applicationId == null) { return false; } if (!registry.validateApplicationId(applicationId)) { return false; } // Limiting service is known to be buggy try { if (!limitingService.checkLimit(user, applicationId)) { if (!limitingService.checkExtendedLimit(user)) { return false; } } } catch (NullPointerException ex) { log.warning("limiting service failed"); return false; } return true; } } |
Обычный тест
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | public class AuthorizationServiceTest { private static final String TOKEN = "TESTTOKEN"; private static final String APP_ID = "TESTAPP"; @Rule public EasyMockRule em = new EasyMockRule(this); @Mock private TokenService tokenService; @Mock private ApplicationRegistry applicationRegistry; @Mock private RequestLimitingService requestLimitingService; @TestSubject private AuthorizationService testedObject = new AuthorizationService(); @Test public void testAuthorizeOk() { User u = new User(); expect(tokenService.validateToken(TOKEN)).andStubReturn(u); expect(tokenService.getApplicationId(TOKEN)).andStubReturn(APP_ID); expect(applicationRegistry.validateApplicationId(APP_ID)).andStubReturn(true); expect(requestLimitingService.checkLimit(u, APP_ID)).andStubReturn(true); replay(tokenService, applicationRegistry, requestLimitingService); assertTrue(testedObject.authorize(TOKEN)); } @Test public void testAuthorizeExtendedOk() { User u = new User(); expect(tokenService.validateToken(TOKEN)).andStubReturn(u); expect(tokenService.getApplicationId(TOKEN)).andStubReturn(APP_ID); expect(applicationRegistry.validateApplicationId(APP_ID)).andStubReturn(true); expect(requestLimitingService.checkLimit(u, APP_ID)).andStubReturn(false); expect(requestLimitingService.checkExtendedLimit(u)).andStubReturn(true); replay(tokenService, applicationRegistry, requestLimitingService); assertTrue(testedObject.authorize(TOKEN)); } //And so long.... } |
Тест с параметрами
А ведь отличаются тесты то только параметрами! Попробуем отрефакторить тест так, чтобы в нём был ровно один тестовый метод. Определим возможные варианты исполнения:
1 2 3 4 5 6 7 8 9 10 11 | @Parameterized.Parameters(name = "{index}: Token: {1}, User: {2}, AppID: {3}, appValidity: {4}, noOveruser: {5}, noExtendedOveruse: {6}, Result: {7}") public static Iterable<Object[]> data() { return Arrays.asList(new Object[][]{ {TOKEN, USER, APP_ID, true, true, null, true}, //Happy path {TOKEN, USER, APP_ID, true, false, true, true}, //Extended overuse {TOKEN, null, null, null, null, null, false}, //Token not valid {TOKEN, USER, null, null, null, null, false}, //No app id {TOKEN, USER, APP_ID, false, null, null, false}, //App id is invalid {TOKEN, USER, APP_ID, true, false, false, false} //Resources overused }); } |
Подтоговим класс теста к использованию параметров:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | @RunWith(Parameterized.class) public class ParameterizedAuthorizationServiceTest { //Test data skipped private String token; private User u; private String appId; private Boolean appValidity; private Boolean overuse; private Boolean extendedOveruse; private Boolean result; public ParameterizedAuthorizationServiceTest(String token, User u, String appId, Boolean appValidity, Boolean overuse, Boolean extendedOveruse, Boolean result) { this.token = token; this.u = u; this.appId = appId; this.appValidity = appValidity; this.overuse = overuse; this.extendedOveruse = extendedOveruse; this.result = result; } |
И перепишем сам тест:
1 2 3 4 5 6 7 8 9 10 11 12 | @Test public void testAuthorize() { expect(tokenService.validateToken(token)).andStubReturn(u); expect(tokenService.getApplicationId(token)).andStubReturn(appId); expect(applicationRegistry.validateApplicationId(appId)).andStubReturn(appValidity); expect(requestLimitingService.checkLimit(u, appId)).andStubReturn(overuse); expect(requestLimitingService.checkExtendedLimit(u)).andStubReturn(extendedOveruse); replay(tokenService, applicationRegistry, requestLimitingService); assertThat(testedObject.authorize(token), is(result)); } |
Класс теста почти не изменился, но зато теперь у нас один тест покрывает все возможные варианты исполнения. Или почти все — мы забыли про кидающий NPE RequestLimitingService Что ж, костыль в коде — костыль в тесте. Немного подправим параметры:
1 2 3 4 5 6 7 8 9 10 11 12 13 | @Parameterized.Parameters(name = "{index}: Token: {1}, User: {2}, AppID: {3}, appValidity: {4}, noOveruser: {5}, noExtendedOveruse: {6}, Result: {7}") public static Iterable<Object[]> data() { return Arrays.asList(new Object[][]{ {TOKEN, USER, APP_ID, true, true, null, true}, //Happy path {TOKEN, USER, APP_ID, true, false, true, true}, //Extended overuse {TOKEN, null, null, null, null, null, false}, //Token not valid {TOKEN, USER, null, null, null, null, false}, //No app id {TOKEN, USER, APP_ID, false, null, null, false}, //App id is invalid {TOKEN, USER, APP_ID, true, false, false, false}, //Resources overused {TOKEN, USER, APP_ID, true, null, null, false}, //NPE in limiting service {TOKEN, USER, APP_ID, true, false, null, false} //NPE in limiting service }); } |
Я добавил две строки, исполнение которых должно привести к возврату из методов RequestLimitingService null. А в тесте я сделаю подпорку под этот null:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | @Test public void testAuthorize() { expect(tokenService.validateToken(token)).andStubReturn(u); expect(tokenService.getApplicationId(token)).andStubReturn(appId); expect(applicationRegistry.validateApplicationId(appId)).andStubReturn(appValidity); if (overuse != null) { expect(requestLimitingService.checkLimit(u, appId)).andStubReturn(overuse); } else { expect(requestLimitingService.checkLimit(u, appId)).andThrow(new NullPointerException()); } if (extendedOveruse != null) { expect(requestLimitingService.checkExtendedLimit(u)).andStubReturn(extendedOveruse); } else { expect(requestLimitingService.checkExtendedLimit(u)).andThrow(new NullPointerException()); } replay(tokenService, applicationRegistry, requestLimitingService); assertThat(testedObject.authorize(token), is(result)); } |
Некрасиво конечно, ну да ладно 🙂 Проверим результаты:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | ------------------------------------------------------- T E S T S ------------------------------------------------------- Running ru.easyjava.easymock.AuthorizationServiceTest Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.004 sec - in ru.easyjava.easymock.AuthorizationServiceTest Running ru.easyjava.easymock.ParameterizedAuthorizationServiceTest рту 17, 2015 4:15:45 PM ru.easyjava.easymock.service.AuthorizationService authorize WARNING: limiting service failed рту 17, 2015 4:15:45 PM ru.easyjava.easymock.service.AuthorizationService authorize WARNING: limiting service failed Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.006 sec - in ru.easyjava.easymock.ParameterizedAuthorizationServiceTest Results : Tests run: 10, Failures: 0, Errors: 0, Skipped: 0 [INFO] ------------------------------------------------------------------------ |
Все тесты проходят 🙂
В чём же профит? Профитов несколько: класс теста не представляет собой нереально длинную портянку почти одинаковых методов, в которых можно запутаться; при изменении тестируемого кода достаточно поменять ровно один тест; из параметров сразу видно, какие значения к какому результату приводят.
Код примера доступен на github.