Разделы портала

Онлайн-тренинги

.
Как писать юнит-тесты, если совсем не хочется
20.02.2019 00:00

Оригинальная публикация

Автор: Тагир Валеев, CC BY 3.0

Всех нас на работе то и дело пытаются заставить писать юнит-тесты. Многие уже поняли, что от них один вред. Написание тестов отнимает много времени, за которое вы могли бы сделать что-то более полезное. Если тест неожиданно начинает падать, ломается сборка на сервере непрерывной интеграции, не выкатывается вовремя релиз, бизнес теряет деньги и крайним оказываетесь вы, автор упавшего юнит-теста. При рефакторинге тесты причиняют головную боль, потому что начинают падать и приходится с этим разбираться.

Тем не менее злые начальники требуют больше тестов, говоря о так называемом «контроле качества». Особо хитрые менеджеры даже считают покрытие и не отпускают вас с работы, пока оно не будет достигнуто. Ваш код заворачивают на ревью, если в нём нет тестов или они чем-то не понравились. Сплошное расстройство!

Что же делать?

К счастью, есть способы писать надёжные юнит-тесты, которые никогда не упадут. Эти способы придумал не я, их успешно практикуют в ряде опенсорсных проектов. Все примеры, которые я приведу, взяты из реального кода. Поэтому нет причин и вам не воспользоваться тем, что уже применяется на практике другими разработчиками!

Самый первый и очевидный способ: ничего не проверять в юнит-тесте. Вот простой пример:

public void testSetFile() {
    System.out.println("setFile");
    File f = null;
    BlastXMLParser instance = new BlastXMLParser();
    instance.setFile(f);
}

Начальник требует стопроцентного покрытия? Отлично, протестируем, что пустой конструктор по умолчанию и тривиальный сеттер не падают с исключением. То что сеттер действительно что-то установил проверять не будем, тем более что по факту мы null перезаписали null'ом. Это надёжно, такой тест падать не должен.

Слишком банально и не удаётся такое пропихнуть на ревью? Можно поступить хитрее:

@Test
public void getParametersTest() {
    List<IGeneratorParameter<?>> parameters = generator.getParameters();
    containsParameterType(parameters, AtomColor.class);
    containsParameterType(parameters, AtomColorer.class);
    containsParameterType(parameters, AtomRadius.class);
    containsParameterType(parameters, ColorByType.class);
    ...
}

Выглядит солидно, будто что-то действительно проверяется. Взглянем однако на метод containsParameterType:

public <T> boolean containsParameterType(List<IGeneratorParameter<?>> list, Class<T> type) {
    for (IGeneratorParameter<?> item : list) {
        if (item.getClass().getName().equals(type.getName())) return true;
    }
    return false;
}

Изящно, правда? Оба метода по отдельности выглядят разумно, но вместе они ничего не проверяют. Такое может прокатить, особенно если методы закоммитить по отдельности и отправить разным ревьюверам. Тем более что они в разных классах!

Однако так долго не протянешь. Злое начальство заподозрит неладное, не увидев никаких ассертов в коде. Ассерты всё-таки добавлять стоит. Но, например, так, чтобы они не выполнялись. Вот грубый подход:

for (int i = 0; i < 0; i++)
{
    Assert.assertTrue(errorProbabilities[i] > 0.0d);
}

Цикл на 0 итераций. Такое пропустит разве что сильно пьяный ревьювер. Однако следующий вариант гораздо изящнее:

List<JavaOperationSignature> sigs = new ArrayList<>();
List<JavaOperationSignature> sigs2 = new ArrayList<>();

for (int i = 0; i < sigs.size(); i++) { // делаем вид, что заполняем списки
    sigs.add(JavaOperationSignature.buildFor(nodes.get(i)));
    sigs2.add(JavaOperationSignature.buildFor(nodes2.get(i)));
}

for (int i = 0; i < sigs.size() - 1; i++) { // делаем вид, что сравниваем
    assertTrue(sigs.get(i) == sigs.get(i + 1));
    assertTrue(sigs2.get(i) == sigs2.get(i + 1));
}

Тут уже многие ревьюверы не заметят подвоха! Оба цикла ни разу не выполняются, потому что граница — размер пустого списка. Берите на заметку.

Предположим, ваш коллега десять лет назад написал тест, проверяющий, что метод кидает исключение. Сделал он это старомодным способом, через catch. Прекрасно, допишите любых новых ассертов в конец метода. Никто не заметит, что они не выполняются:

try {
    getDs().save(e);
} catch (Exception ex) {
    return; // нормальный выход из теста здесь!
}

// Следующая строчка выполнится, если что-то пойдёт не так
Assert.assertFalse("Should have got rejection for dot in field names", true); 
// А это не выполнится никогда
e = getDs().get(e);
Assert.assertEquals("a", e.mymap.get("a.b")); // Но никто этого не заметит!
Assert.assertEquals("b", e.mymap.get("c.e.g"));

Ваши менеджеры совсем обнаглели и смотрят на покрытие не только основного кода, но и тестов? Теперь они замечают такие штуки? Ладно, и с этим можно бороться. Будем писать ассерты, которые проверяют всякую ерунду. Например, что свежесозданный объект не равен null:

Assert.assertNotNull(new Electronegativity());

Если оператор new у вас возвращает null, то у вашей виртуальной машины серьёзные проблемы. Поэтому такой ассерт надёжен как скала. Хотя, конечно, опытному ревьюверу сразу бросится в глаза. Более хитрый способ обмануть систему — проверить булево значение:

DocumentImplementation document = new DocumentImplementation(props);
assertNotNull(document.toString().contains(KEY));
assertNotNull(document.toString().contains(VALUE));

Благодаря автобоксингу примитивный boolean заворачивается в объектный Boolean, который, конечно, никогда не будет нуллом. Этот ассерт уже не так бросается в глаза, честно выполняется и ему совершенно наплевать, true там вернётся или false. Подобный фокус работает и с другими примитивными типами:

Assert.assertNotNull("could not get nr. of eqr: ", afpChain.getNrEQR());

В отрыве от контекста код выглядит вполне разумно. Ревьюверу придётся проявить истинную дотошность, чтобы заметить, что метод getNrEQR возвращает примитивный int и поэтому такой ассерт не может упасть.

Ещё отличный способ ничего не проверить — написать длинное сообщение к ассерту с конкатенацией разных компонентов, а второй аргумент вообще убрать:

Assert.assertNotNull("Attempt to test atom type which is not defined in the " +
     getAtomTypeListName() + ": " + exception.getMessage());

Видите? Кажется, раз у нас длинное-длинное сообщение, то проверяется что-то серьёзное. На самом деле проверяется, что это самое сообщение не равно null, чего не может быть, потому что конкатенация строк в джаве всегда выдаст ненулевой объект.

Вообще, конечно, если хочется бросить пыль в глаза, то assertNotNull ваш лучший друг. Но не единственный друг! К примеру, assertEquals прекрасно можно использовать, чтобы сравнивать число с самим собой:

Assert.assertEquals(ac2.getAtomCount(), ac2.getAtomCount());

А если вас поймают за руку, всегда можно оправдаться, что вы проверяли стабильность метода getAtomCount. Может кто-нибудь вместо простого геттера туда генератор случайных чисел засунет!

Если вы работаете с типом double, то самое время вспомнить про сравнение с NaN. К сожалению, assertNotEquals тут вам не помощник, он слишком умный. Но всегда можно использовать assertTrue:

Assert.assertTrue(result1.get(i) != Double.NaN);

Как известно, NaN ничему не равен, даже самому себе, а потому такое сравнение всегда истинно.

Также assertTrue полезен для очевидных instanceof-проверок:

Assert.assertNotNull(cf.getRealFormat());
Assert.assertNotNull(cf.getImaginaryFormat());
Assert.assertTrue(cf.getRealFormat() instanceof NumberFormat);
Assert.assertTrue(cf.getImaginaryFormat() instanceof NumberFormat);

Методы getRealFormat и getImaginaryFormat и так возвращают NumberFormat, так что instanceof проверяет разве что неравенство нуллу. Но на нулл мы и так проверили выше. Таким нехитрым образом число ассертов можно увеличить вдвое.

Есть ещё ряд способов, по которым я быстро не нашёл примеров. Скажем, можно использовать метод assertThat из AssertJ и не воспользоваться результатом (например, assertThat(somethingIsTrue()) вместо assertThat(somethingIsTrue()).is(true)). Можно завернуть текст теста в большой try { ... } catch(Throwable t) {} так чтобы поймать и проигнорировать AssertionError. Такого же эффекта можно добиться хитрее. Например, отправить ассерты в фоновый поток через CompletableFuture.runAsync() и не сджойнить результат. Я уверен, вы и сами придумаете множество способов, чтобы противостоять менеджерскому произволу.

Но будьте осторожны. Начальство ни в коем случае не должно узнать про статический анализ. Иначе всё зря. Большинство упомянутых в статье способов, к сожалению, легко находятся хорошим статическим анализатором. Так что т-с-с-с!

Обсудить в форуме