понедельник, 23 июля 2018 г.

Модульное тестирование для самых маленьких


Доброго времени суток, дорогой друг!
Программы - они как люди!
Как люди они рождаются! Как люди они учатся! И прежде, чем вступить во взрослую жизнь - они, как и люди, должны сдать экзамены. Или другими словами - пройти тесты!

Сегодня я хочу поговорить о модульных тестах. Модульное тестирование - это важное событие в жизни любой программы. Для того, чтобы ваша программа была здорова и счастлива - она должна проходить модульные тесты часто, регулярно и на систематической основе, как посещение врача. И чем больше модульных тестов - тем более полную информацию о здоровье вашей программы вы - как заботливый родитель - будете иметь.
А ведь мы не хотим, чтобы в нашей программе завелись баги? Так? Так???
Поэтому сегодня я расскажу о том, как же сделать так, чтобы ваша программка выросла большой и сильной. А теперь - пока она мирно спит, не подозревая о своем великом будущем - на цыпочках - за мной!



Рассмотрим модульное тестирование в контексте инфраструктуры Maven. Каждый раз, когда вы используете Maven, чтобы собрать проект, ваша программа в обязательном порядке проходит фазу модульного тестирования. Модульное тестирование - самый низкоуровневый способ тестирования динамического поведения кода (ниже только статические способы верификации - проверка лексики, синтаксиса и семантики со стороны компилятора + статические проверки кода специальными инструментами типа PMD).

Требования
Поскольку модульное тестирование является частью процесса сборки, к нему предъявляются следующие требования:
  1. максимально высокая скорость выполнения - поэтому тесты не должны участвовать в операциях ввода-вывода (то есть никаких соединений к СУБД, серверам, никаких сохранений файликов и чтения из них не должно быть в модульных тестах);
  2. тесты не должны падать и тормозить сборку ни по каким причинам, кроме некорректности исходного кода - падение теста должно сигнализировать о некорректности кода и больше ни о чем;
  3. тесты должны быть понятными - они являются описанием того, что мы ждем от классов и методов. Это должно легко пониматься и изменяться;
  4. тесты должны быть переносимыми и кроссплатформенными;

Влияние на код
Требование "тестируемости" кода налагает ряд ограничений на код, который в целом приводит к более стройному стилю разработки:
  • Использование интерфейсов вместо классов;
  • Использование 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> 

Простой пример
Простой пример теста - когда не нужно использовать mock-объектов - довольно тривиален. Допустим есть код:
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:

<?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 случая работы метода:
  1. sql == null;
  2. sql != null; Запрос не возвращает строк;
  3. sql != null; Запрос возвращает минимум одну строку;
Для каждого случая так же нужно проверить, что метод close() вызывался для объектов Statement и ResultSet. В итоге тест выглядит так:
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

Тестирование кода, работающего с файловой системой
Основная проблема тестирования кода, работающего с файлами, в том, что аргументами методов обычно выступают объекты класса File, который является просто оберткой вокруг пути и не имеет особого отношения к операциям записи и чтения данных (за это отвечают классы FileInputStream и FileOutputStream).
Поэтому бессмысленно делать 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.

Комментариев нет:

Отправить комментарий