Параметризованные тесты, предположения, теории и правила покрывают 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.