Доброго времени суток, дорогой друг!
Программы - они как люди!
Как люди они рождаются! Как люди они учатся! И прежде, чем вступить во взрослую жизнь - они, как и люди, должны сдать экзамены. Или другими словами - пройти тесты!
Сегодня я хочу поговорить о модульных тестах. Модульное тестирование - это важное событие в жизни любой программы. Для того, чтобы ваша программа была здорова и счастлива - она должна проходить модульные тесты часто, регулярно и на систематической основе, как посещение врача. И чем больше модульных тестов - тем более полную информацию о здоровье вашей программы вы - как заботливый родитель - будете иметь.
А ведь мы не хотим, чтобы в нашей программе завелись баги? Так? Так???
Поэтому сегодня я расскажу о том, как же сделать так, чтобы ваша программка выросла большой и сильной. А теперь - пока она мирно спит, не подозревая о своем великом будущем - на цыпочках - за мной!
Рассмотрим модульное тестирование в контексте инфраструктуры Maven. Каждый раз, когда вы используете Maven, чтобы собрать проект, ваша программа в обязательном порядке проходит фазу модульного тестирования. Модульное тестирование - самый низкоуровневый способ тестирования динамического поведения кода (ниже только статические способы верификации - проверка лексики, синтаксиса и семантики со стороны компилятора + статические проверки кода специальными инструментами типа PMD).
Влияние на код
Модульное тестирование в Maven
Такими объектами могут быть соединение с СУБД, файловая система и т.п.
Для создания mock-объектов существует множество готовых библиотек - например Mockito. Если их планируется использовать в тестах - mockito нужно добавить как зависимость в pom.xml:
Простой пример
Простой пример теста - когда не нужно использовать mock-объектов - довольно тривиален. Допустим есть код:
pom.xml при этом выглядит так:
При сборке этого проекта мавеном, в логе сборки должны присутствовать такие строчки:
Пример посложнее
Тестирование кода, работающего с файловой системой
Основная проблема тестирования кода, работающего с файлами, в том, что аргументами методов обычно выступают объекты класса File, который является просто оберткой вокруг пути и не имеет особого отношения к операциям записи и чтения данных (за это отвечают классы FileInputStream и FileOutputStream).
Поэтому бессмысленно делать mock-объект класса File, необходимо переопределить операции записи в файл и чтения, создав соответствующую структуру класса. Допустим у нас есть класс, который является реализацией счетчика, значение которого хранится в файле:
Поэтому часто тесты, если и существуют, не служат полной защитой от ошибок, так как не покрывают все варианты работы методов. Для того, чтобы оценить, насколько модульные тесты охватывают исходный код, используются разные способы:
Оценка степени покрытия
Инструменты измеряющие степень покрытия показывают процент покрытия методов и классов тестами.
Самый популярный инструмент такого класса - Coverage (в Intellij Idea есть встроенный плагин, можно пользоваться).
Мутационные тесты
Мутационные тесты - это инструмент, который изменяет исходный код (или байт-код) программы, прогоняет модульные тесты для измененного кода и проверяет, обнаружил ли модульный тест изменение.
Если существуют такие изменения кода (мутации), которые не обнаруживаются модульными тестами, значит набор модульных тестов неполон/некорректен и необходимо его расширить.
Как пример библиотеки мутационного тестирования можно назвать PiTest.
Программы - они как люди!
Как люди они рождаются! Как люди они учатся! И прежде, чем вступить во взрослую жизнь - они, как и люди, должны сдать экзамены. Или другими словами - пройти тесты!
Сегодня я хочу поговорить о модульных тестах. Модульное тестирование - это важное событие в жизни любой программы. Для того, чтобы ваша программа была здорова и счастлива - она должна проходить модульные тесты часто, регулярно и на систематической основе, как посещение врача. И чем больше модульных тестов - тем более полную информацию о здоровье вашей программы вы - как заботливый родитель - будете иметь.
А ведь мы не хотим, чтобы в нашей программе завелись баги? Так? Так???
Поэтому сегодня я расскажу о том, как же сделать так, чтобы ваша программка выросла большой и сильной. А теперь - пока она мирно спит, не подозревая о своем великом будущем - на цыпочках - за мной!
Рассмотрим модульное тестирование в контексте инфраструктуры Maven. Каждый раз, когда вы используете Maven, чтобы собрать проект, ваша программа в обязательном порядке проходит фазу модульного тестирования. Модульное тестирование - самый низкоуровневый способ тестирования динамического поведения кода (ниже только статические способы верификации - проверка лексики, синтаксиса и семантики со стороны компилятора + статические проверки кода специальными инструментами типа PMD).
Требования
Поскольку модульное тестирование является частью процесса сборки, к нему предъявляются следующие требования:- максимально высокая скорость выполнения - поэтому тесты не должны участвовать в операциях ввода-вывода (то есть никаких соединений к СУБД, серверам, никаких сохранений файликов и чтения из них не должно быть в модульных тестах);
- тесты не должны падать и тормозить сборку ни по каким причинам, кроме некорректности исходного кода - падение теста должно сигнализировать о некорректности кода и больше ни о чем;
- тесты должны быть понятными - они являются описанием того, что мы ждем от классов и методов. Это должно легко пониматься и изменяться;
- тесты должны быть переносимыми и кроссплатформенными;
Влияние на код
Требование "тестируемости" кода налагает ряд ограничений на код, который в целом приводит к более стройному стилю разработки:
- Использование интерфейсов вместо классов;
- Использование dependency-injection;
- Избегание неочевидных зависимостей, синглтонов и т.п.;
Модульное тестирование в Maven
В экосистеме Maven при любой сборке происходит автоматический поиск, компиляция и запуск модульных тестов, если они есть. Для того, чтобы ваши тесты запускались, необходимо:
- Создать папку src/test/java как корень исходного кода тестов;
- Добавить в pom.xml зависимость для библиотеки junit:
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.0</version> <scope>test</scope> </dependency>
- в папке для тестов сложить классы тестов. Имена классов должны заканчиваться словом Test. Они будут запускаться в момент сборки.
Эмуляция
Очень часто создание модульных тестов связано с созданием mock-объектов, то есть объектов, имитирующих поведение реальных объектов, но используемые в тестах как их замена из-за запрета на использование операций ввода-вывода;Такими объектами могут быть соединение с СУБД, файловая система и т.п.
Для создания mock-объектов существует множество готовых библиотек - например Mockito. Если их планируется использовать в тестах - mockito нужно добавить как зависимость в pom.xml:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.8.4</version>
<scope>test</scope>
</dependency>
Простой пример
package com.blogspot.toolkas;
public class Add {
public int process(int val1, int val2) {
return val1 + val2;
}
}
Не глядя на реализацию метода process (а еще лучше - до реализации метода process - читаем про TDD), мы должны написать тест, который описывает то, что, как мы думаем, должен делать метод process:
package com.blogspot.toolkas;
import junit.framework.Assert;
import org.junit.Test;
public class AddTest {
@Test
public void testProcess() {
Add add = new Add();
Assert.assertEquals(1, add.process(1, 0));
Assert.assertEquals(1, add.process(0, 1));
Assert.assertEquals(0, add.process(11, -11));
Assert.assertEquals(20, add.process(1, 19));
}
}
В данном простом случае тестирование сводится к проверке операции сложения на нескольких произвольных значениях.pom.xml при этом выглядит так:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.blogspot.toolkas</groupId>
<artifactId>example-test</artifactId>
<name>example-test</name>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
А структура проекта так:
При сборке этого проекта мавеном, в логе сборки должны присутствовать такие строчки:
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.blogspot.toolkas.AddTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.017 sec
Results :
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
Пример посложнее
Допустим, необходимо протестировать утилитный метод, который делает запрос в СУБД и возвращает одну строку.
Если записей в СУБД нет - возвращается значение по умолчанию:
Если записей в СУБД нет - возвращается значение по умолчанию:
package com.blogspot.toolkas;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Objects;
public class JdbcUtils {
private JdbcUtils() {
}
public static String getString(Connection connection, String sql,
String columnName, String defaultValue) throws SQLException {
Objects.requireNonNull(sql);
try(Statement statement = connection.createStatement()) {
try(ResultSet resultSet = statement.executeQuery(sql)) {
if (resultSet.next()) {
return resultSet.getString(columnName);
}
}
}
return defaultValue;
}
}
Основная сложность при тестировании метода getString - протестировать корректность выполнения запросов к СУБД без самой СУБД (помним про запрет выполнения операций ввода-вывода).
Для эмуляции соединения к СУБД используем Mockito.
Добавим зависимость Mockito в pom.xml:
Для эмуляции соединения к СУБД используем Mockito.
Добавим зависимость Mockito в pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.blogspot.toolkas</groupId>
<artifactId>example-test</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>7</source>
<target>7</target>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.8.4</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Проверим 3 случая работы метода:- sql == null;
- sql != null; Запрос не возвращает строк;
- sql != null; Запрос возвращает минимум одну строку;
package com.blogspot.toolkas;
import junit.framework.Assert;
import org.junit.Test;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class JdbcUtilsTest {
@Test
public void testSelectIfRecodsMissing() throws SQLException {
Connection connection = mock(Connection.class); //создаем mock для Connection
Statement statement = mock(Statement.class); //создаем mock для Statement
ResultSet resultSet = mock(ResultSet.class); //создаем mock для ResultSet
when(connection.createStatement()).thenReturn(statement); //связываем connection и statement
String sql = "SELECT object_name FROM dm_document";
when(statement.executeQuery(sql)).thenReturn(resultSet); //связываем statement и resultSet
when(resultSet.next()).thenReturn(false);//задаем поведение для resultSet
String value = JdbcUtils.getString(connection, sql, "object_name", "NO_VALUE");
Assert.assertEquals("NO_VALUE", value);//проверяем, что возвращается значение по умолчанию
verify(resultSet).next();//проверяем, что вызывался метод next() у resulSet
verify(resultSet).close();//проверяем, что resultSet закрыли после использования
verify(statement).close();//проверяем, что statement закрыли после использования
}
@Test
public void testSelectIfRecordsExist() throws SQLException {
Connection connection = mock(Connection.class);
Statement statement = mock(Statement.class);
ResultSet resultSet = mock(ResultSet.class);
when(connection.createStatement()).thenReturn(statement);
String sql = "SELECT object_name FROM dm_document";
when(statement.executeQuery(sql)).thenReturn(resultSet);
when(resultSet.next()).thenReturn(true);
when(resultSet.getString("object_name")).thenReturn("TEST");
String value = JdbcUtils.getString(connection, sql, "object_name", "NO_VALUE");
Assert.assertEquals("TEST", value);
verify(resultSet).next();
verify(resultSet).getString("object_name");
verify(resultSet).close();
verify(statement).close();
}
@Test(expected = NullPointerException.class)
public void testSelectNull() throws SQLException {
Connection connection = mock(Connection.class);
JdbcUtils.getString(connection, null, "object_name", "TEST");
}
}
В каждом тесте мы создаем mock-Объекты и устанавливаем связи между ними. Утилитный метод работает так, как он работал бы с живой СУБД.
В логе сборки должны быть строки о запуске новых тестов:
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.blogspot.toolkas.AddTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 sec
Running com.blogspot.toolkas.JdbcUtilsTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.285 sec
Results :
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
Тестирование кода, работающего с файловой системой
Поэтому бессмысленно делать mock-объект класса File, необходимо переопределить операции записи в файл и чтения, создав соответствующую структуру класса. Допустим у нас есть класс, который является реализацией счетчика, значение которого хранится в файле:
package com.blogspot.toolkas;
import java.io.*;
import java.util.Scanner;
public class Counter {
private static final int INIT = 0;
private final File file;
public Counter(File file) {
this.file = file;
}
public int incrementAndGet() throws IOException {
int value = readValue();
value += 1;
writeValue(value);
return value;
}
private int readValue() throws IOException {
if (file.exists()) {
try(Scanner scanner = new Scanner(file)) {
if (scanner.hasNextLine()) {
String line = scanner.nextLine();
return Integer.parseInt(line.trim());
}
}
}
return INIT;
}
private void writeValue(int value) throws FileNotFoundException {
try(PrintWriter writer = new PrintWriter(file)) {
writer.print(value);
}
}
}
Как видим нам нужно протестировать единственный публичный метод incrementAndGet(), причем учесть случай, когда файла при первом обращении еще нет и случай, когда он есть, но в нем нет значения и случай, когда файл есть и в нем есть корректное значение. Можно придумать еще большое количество кейсов, но остановимся на этих трех.
Для успешного тестирования необходимо иметь возможность отделить код логики Counter от кода записи и чтения файла.
Это можно сделать разными способами, но самое простое - иметь возможность переопределить методы readValue и writeValue для того, чтобы присвоить им немного другую реализацию в тестовом окружении.
Тогда класс Counter будет выглядеть так:package com.blogspot.toolkas;
import java.io.*;
import java.util.Scanner;
public class Counter {
protected static final int INIT = 0;
private final File file;
public Counter(File file) {
this.file = file;
}
public int incrementAndGet() throws IOException {
int value = readValue();
value += 1;
writeValue(value);
return value;
}
protected int readValue() throws IOException {
if (file.exists()) {
try(Scanner scanner = new Scanner(file)) {
if (scanner.hasNextLine()) {
String line = scanner.nextLine();
return Integer.parseInt(line.trim());
}
}
}
return INIT;
}
protected void writeValue(int value) throws FileNotFoundException {
try(PrintWriter writer = new PrintWriter(file)) {
writer.print(value);
}
}
}
А его тест - так:
package com.blogspot.toolkas;
import org.junit.Assert;
import org.junit.Test;
import static org.mockito.Mockito.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
public class CounterTest {
@Test
public void testInitValue() throws IOException {
File file = mock(File.class);
when(file.exists()).thenReturn(false);
final AtomicReference reference = new AtomicReference(Counter.INIT);
Counter counter = new Counter(file) {
@Override
protected int readValue() throws IOException {
return reference.get();
}
@Override
protected void writeValue(int value) throws FileNotFoundException {
reference.set(value);
}
};
Assert.assertEquals(1, counter.incrementAndGet());
Assert.assertEquals(1, reference.get());
}
}
То есть вместо записи и чтения файла мы сохраняем значение в переменную AtomicReference, которая в нашем случае играет роль файловой системы.
Минус этого теста в том, что мы изменяем слишком много логики класса Counter (например начисто выпадает поведение класса при file.exists() == false и при file.exists() == true), поэтому можно пойти другим путем.
Попытаемся сохранить логику класса Counter, изменив лишь потоки ввода-вывода. Для этого нам нужно иметь возможность переопределить код создания потоков, поэтому перепишем класс так:
package com.blogspot.toolkas;
import java.io.*;
import java.util.Scanner;
public class Counter {
protected static final int INIT = 0;
private final File file;
public Counter(File file) {
this.file = file;
}
public int incrementAndGet() throws IOException {
int value = readValue();
value += 1;
writeValue(value);
return value;
}
protected int readValue() throws IOException {
if (file.exists()) {
try(Scanner scanner = new Scanner(createInputStream())) {
if (scanner.hasNextLine()) {
String line = scanner.nextLine();
return Integer.parseInt(line.trim());
}
}
}
return INIT;
}
protected void writeValue(int value) throws FileNotFoundException {
try(PrintWriter writer = new PrintWriter(createOutputStream())) {
writer.print(value);
}
}
protected InputStream createInputStream() throws FileNotFoundException {
return new FileInputStream(file);
}
protected OutputStream createOutputStream() throws FileNotFoundException {
return new FileOutputStream(file);
}
}
Теперь мы можем написать более детальный тест, переопределяя методы создания потоков:
package com.blogspot.toolkas;
import org.junit.Assert;
import org.junit.Test;
import static org.mockito.Mockito.*;
import java.io.*;
public class CounterTest {
@Test
public void testInitValue() throws IOException {
File file = mock(File.class);
when(file.exists()).thenReturn(false);
FakeFileStream stream = new FakeFileStream();
Counter counter = createCounter(file, stream);
Assert.assertEquals(1, counter.incrementAndGet());
Assert.assertEquals(1, Integer.parseInt(new String(stream.bytes, "UTF-8")));
}
@Test
public void testExistsValue() throws IOException {
File file = mock(File.class);
when(file.exists()).thenReturn(true);
FakeFileStream stream = new FakeFileStream();
stream.bytes = "123".getBytes("UTF-8");
Counter counter = createCounter(file, stream);
Assert.assertEquals(124, counter.incrementAndGet());
Assert.assertEquals(124, Integer.parseInt(new String(stream.bytes, "UTF-8")));
}
private Counter createCounter(final File file, final FakeFileStream stream) {
return new Counter(file) {
@Override
protected InputStream createInputStream() throws FileNotFoundException {
return new ByteArrayInputStream(stream.bytes);
}
@Override
protected OutputStream createOutputStream() throws FileNotFoundException {
return new ByteArrayOutputStream(){
@Override
public void close() throws IOException {
stream.bytes = toByteArray();
}
};
}
};
}
private static class FakeFileStream {
private byte[] bytes;
}
}
Один из методов проверяет случай, когда файла не существует.
Второй метод - когда файл существует и в нем записано число 123;
Качество модульного тестирования
Как было уже сказано, модульные тесты - один из самых примитивных видов автоматического тестирования. Внедрение их очень трудоемко (иногда нерентабельно - дешевле использовать другие способы тестирования), поскольку на один метод реального кода приходится писать несколько методов тестирования. При росте цикломатической сложности исходного кода количество тестового кода возрастает пропорционально.Поэтому часто тесты, если и существуют, не служат полной защитой от ошибок, так как не покрывают все варианты работы методов. Для того, чтобы оценить, насколько модульные тесты охватывают исходный код, используются разные способы:
Оценка степени покрытия
Самый популярный инструмент такого класса - Coverage (в Intellij Idea есть встроенный плагин, можно пользоваться).
Мутационные тесты
Если существуют такие изменения кода (мутации), которые не обнаруживаются модульными тестами, значит набор модульных тестов неполон/некорректен и необходимо его расширить.
Как пример библиотеки мутационного тестирования можно назвать PiTest.
Комментариев нет:
Отправить комментарий