Параметризованные тесты, предположения, теории и правила покрывают 95% всего необходимого для написания юнит-тестов функционала. Оставшимися пять процентами занимается тяжёлая артиллерия: собственные test runners.
Подготовка
Для иллюстрации возьмём LocalizedDateService из примера с предположениями и перепишем его, чтобы проверять значения для нескольких локалей. Строго говоря, такой тест было бы гораздо проще и лучше написать с использованием параметров, но наша цель собственные test runners, а более-менее реалистичного простого сценария, требующего этого, придумать не удалось.
Добавим в класс сервиса знания о локалях:
1 2 3 4 5 6 7 8 9 10 | /** * Formats date in a human friendly form. * @param date Date to format. * @return Formatted date with locale taken into account. */ public final String formatDate(final Date date) { DateFormat dateFormat = new SimpleDateFormat("EEE, MMM d, ''yy", new Locale(LocaleHolder.getLocale())); return dateFormat.format(date); } |
LocaleHolder — это простой класс, оборачивающий статическую переменную, хранящую текущую локаль. Тоже не самое лучшее решение, но для наших нужд сойдёт.
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 | /** * Keeps current application locale. * * Not thread-safe. */ public final class LocaleHolder { /** * Locale data. */ private static String locale; /** * Do not construct me. */ private LocaleHolder() { }; /** * Gets current locale. * @return locale value. */ public static String getLocale() { return locale; } /** * Sets current locale. * @param newLocale new value. */ public static void setLocale(final String newLocale) { LocaleHolder.locale = newLocale; } } |
И тест, который умеет локали:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public class LocalizedDateServiceTest { private LocalizedDateService testedObject = new LocalizedDateService(); @Test public void testFormatDate() throws Exception { LocaleHolder.setLocale("ru"); Calendar date = Calendar.getInstance(); date.set(1961, 4, 21, 9, 7, 0); assertThat(testedObject.formatDate(date.getTime()), is("Вс, мая 21, '61")); } @Test public void testFormatDateTW() throws Exception { LocaleHolder.setLocale("th"); Calendar date = Calendar.getInstance(); date.set(1961, 4, 21, 9, 7, 0); assertThat(testedObject.formatDate(date.getTime()), is("อา., พ.ค. 21, '61")); } } |
Собственный runner
Наш runner будет выставлять локаль и передавать ожидаемое значение в тест. Опять же, эту проблему можно решить с помощью параметризованного теста, но мы не ищём лёгких путей.
Runner состоит из двух частей. В первой части мы оборачиваем Suite, что позволит нам породить несколько тестов из одного:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public class Locales extends Suite { private static Map<String, String> localesToUse; static { localesToUse = new HashMap<String, String>(); localesToUse.put("ru", "Вс, мая 21, '61"); localesToUse.put("th", "อา., พ.ค. 21, '61"); localesToUse.put("de", "So, Mai 21, '61"); localesToUse.put("ja", "日, 5 21, '61"); localesToUse.put("ar", "ح, ماي 21, '61"); } public Locales(Class<?> clazz) throws InitializationError { super(clazz, prepareRunners(clazz)); } private static List<Runner> prepareRunners(Class<?> clazz) throws InitializationError { List<Runner> result = new ArrayList<Runner>(); for (String locale : localesToUse.keySet()) { result.add(new LocalesRunner(locale, localesToUse.get(locale), clazz)); } return result; } } |
В runner перечислены локали и отзывы на них, реализован требуемый контрактом конструктор и, самое главное, формируется список из тестов с разными локалями.
Говоря проще, Suite — это, в терминах JUnit, набор из нескольких классов с тестами, исполняемый как один большой класс с тестами. Locales использует понятие Suite в своих грязных целях — в Suite добавляется один и тот же (фактически) класс, но с разными настройками LocalesRunner. Тем самым мы исполняем тестовый класс столько раз, сколько у нас локалей в конфиге.
Вторая часть — непосредственно runner для одного конкретного класса теста:
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 | public class LocalesRunner extends BlockJUnit4ClassRunner { private String locale; private String result; public LocalesRunner(String locale, String result, Class<?> clazz) throws InitializationError { super(clazz); this.locale = locale; this.result = result; } @Override protected void validatePublicVoidNoArgMethods(Class<? extends Annotation> annotation, boolean isStatic, List<Throwable> errors) { List<FrameworkMethod> methods = getTestClass().getAnnotatedMethods(annotation); for (FrameworkMethod eachTestMethod : methods) { eachTestMethod.validatePublicVoid(isStatic, errors); } } @Override protected Statement methodBlock(final FrameworkMethod method) { return new Statement() { @Override public void evaluate() throws Throwable { LocaleHolder.setLocale(locale); method.getMethod().invoke(getTestClass().getOnlyConstructor().newInstance(), result); } }; } @Override// The name of the test class protected String getName() { return String.format("%s [%s]", super.getName(), locale); } @Override// The name of the test method protected String testName(final FrameworkMethod method) { return String.format("%s [%s]", method.getName(), locale); } } |
LocalesRunner порождён от runner’а по умолчанию BlockJUnit4ClassRunner.
Переопределённые методы getName() и testName() очевидны и служат для украшения вывода тестов.
Переопределение validatePublicVoidNoArgMethods() — это костыль. JUnit требует, чтобы тестовые методы были публичными, ничего не возвращали и не принимали никаких параметров. Базовый класс автоматически проверяет методы на соответствие этим требованиям. Но. Наш runner требует методов с параметрами, поэтому я переопределил код проверки и исключил из него проверку на отсуствие аргументов.
Переопределённый methodBlock() возвращает Statement с тестом внутри. На самом деле главным здесь будет метод evaluate(), который выставляет сохранённую в LocalesRunner локаль и вызывает тестовый метод с параметром. Обратите внимание, что в данном примере не будут работать правила, предположения, методы @Before/@After и т.д. Их поддержку я исключил чтоб не загромождать деталями код примера.
Впрочем, поддержку функционала JUnit добавить несложно, например, изменив methodBlock следующим образом:
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 | @Override protected Statement methodBlock(final FrameworkMethod method) { Object test; try { test = new ReflectiveCallable() { @Override protected Object runReflectiveCall() throws Throwable { return createTest(); } }.run(); } catch (Throwable e) { return new Fail(e); } Statement statement = new Statement() { @Override public void evaluate() throws Throwable { LocaleHolder.setLocale(locale); method.getMethod().invoke(getTestClass().getOnlyConstructor().newInstance(), result); } }; statement = possiblyExpectingExceptions(method, test, statement); statement = withPotentialTimeout(method, test, statement); statement = withBefores(method, test, statement); statement = withAfters(method, test, statement); return statement; } |
Тест использует специальную аннотацию @RunWith для запуска с нашим runner и необычные функции с параметрами:
1 2 3 4 5 6 7 8 9 10 11 12 | @RunWith(Locales.class) public class RunnerDateServiceTest { private LocalizedDateService testedObject = new LocalizedDateService(); @Test public void testFormatDate(String result) throws Exception { Calendar date = Calendar.getInstance(); date.set(1961, 4, 21, 9, 7, 0); assertThat(testedObject.formatDate(date.getTime()), is(result)); } } |
При запуске теста видно, что не смотря на то, что в классе теста только один тестовый метод, реально исполняется несколько тестов:
1 2 3 4 5 6 7 8 9 10 11 | ------------------------------------------------------- T E S T S ------------------------------------------------------- Running ru.easyjava.junit.LocalizedDateServiceTest Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.003 sec - in ru.easyjava.junit.LocalizedDateServiceTest Running ru.easyjava.junit.RunnerDateServiceTest Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.002 sec - in ru.easyjava.junit.RunnerDateServiceTest Results : Tests run: 7, Failures: 0, Errors: 0, Skipped: 0 |
Код примера доступен на github.