Говнокодер
0,0
рейтинг
27 мая 2011 в 13:54

Разработка → Тестирование в Java. JUnit tutorial

TDD*, Java*

Сегодня все большую популярность приобретает test-driven development(TDD), техника разработки ПО, при которой сначала пишется тест на определенный функционал, а затем пишется реализация этого функционала. На практике все, конечно же, не настолько идеально, но в результате код не только написан и протестирован, но тесты как бы неявно задают требования к функционалу, а также показывают пример использования этого функционала.

Итак, техника довольно понятна, но встает вопрос, что использовать для написания этих самых тестов? В этой и других статьях я хотел бы поделиться своим опытом в использовании различных инструментов и техник для тестирования кода в Java.

Ну и начну с, пожалуй, самого известного, а потому и самого используемого фреймворка для тестирования — JUnit. Используется он в двух вариантах JUnit 3 и JUnit 4. Рассмотрю обе версии, так как в старых проектах до сих пор используется 3-я, которая поддерживает Java 1.4.

Я не претендую на автора каких-либо оригинальных идей, и возможно многим все, о чем будет рассказано в статье, знакомо. Но если вам все еще интересно, то добро пожаловать под кат.

JUnit 3


Для создания теста нужно унаследовать тест-класс от TestCase, переопределить методы setUp и tearDown если надо, ну и самое главное — создать тестовые методы(должны начинаться с test). При запуске теста сначала создается экземляр тест-класса(для каждого теста в классе отдельный экземпляр класса), затем выполняется метод setUp, запускается сам тест, ну и в завершение выполняется метод tearDown. Если какой-либо из методов выбрасывает исключение, тест считается провалившимся.

Примечание: тестовые методы должны быть public void, могут быть static.

Сами тесты состоят из выполнения некоторого кода и проверок. Проверки чаще всего выполняются с помощью класса Assert хотя иногда используют ключевое слово assert.

Рассмотрим пример. Есть утилита для работы со строками, есть методы для проверки пустой строки и представления последовательности байт в виде 16-ричной строки:
public abstract class StringUtils {
  private static final int HI_BYTE_MASK = 0xf0;
  private static final int LOW_BYTE_MASK = 0x0f;

  private static final char[] HEX_SYMBOLS = {
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
  };

  public static boolean isEmpty(final CharSequence sequence) {
    return sequence == null || sequence.length() <= 0;
  }

  public static String toHexString(final byte[] data) {
    final StringBuffer builder = new StringBuffer(2 * data.length);
    for (byte item : data) {
      builder.append(HEX_SYMBOLS[(HI_BYTE_MASK & item) >>> 4]);
      builder.append(HEX_SYMBOLS[(LOW_BYTE_MASK & item)]);
    }
    return builder.toString();
  }
}

Напишем для нее тесты, используя JUnit 3. Удобнее всего, на мой взгляд, писать тесты, рассматривая нейкий класс как черный ящик, писать отдельный тест на каждый значимый метод в этом классе, для каждого набора входных параметров какой-то ожидаемый результат. Например, тест для isEmpty метода:
  public void testIsEmpty() {
    boolean actual = StringUtils.isEmpty(null);
    assertTrue(actual);

    actual = StringUtils.isEmpty("");
    assertTrue(actual);

    actual = StringUtils.isEmpty(" ");
    assertFalse(actual);

    actual = StringUtils.isEmpty("some string");
    assertFalse(actual);
  }

Можно разделить данные и логику теста, перенеся создание данных в метод setUp:
public class StringUtilsJUnit3Test extends TestCase {
  private final Map toHexStringData = new HashMap();

  protected void setUp() throws Exception {
    toHexStringData.put("", new byte[0]);
    toHexStringData.put("01020d112d7f", new byte[] { 1, 2, 13, 17, 45, 127 });
    toHexStringData.put("00fff21180", new byte[] { 0, -1, -14, 17, -128 });
    //...
  }

  protected void tearDown() throws Exception {
    toHexStringData.clear();
  }

  public void testToHexString() {
    for (Iterator iterator = toHexStringData.keySet().iterator(); iterator.hasNext();) {
      final String expected = (String) iterator.next();
      final byte[] testData = (byte[]) toHexStringData.get(expected);
      final String actual = StringUtils.toHexString(testData);
      assertEquals(expected, actual);
    }
  }

  //...
}

Дополнительные возможности


Кроме того, что было описано, есть еще несколько дополнительных возможностей. Например, можно группировать тесты. Для этого нужно использовать класс TestSuite:
public class StringUtilsJUnit3TestSuite extends TestSuite {
  public StringUtilsJUnit3TestSuite() {
    addTestSuite(StringUtilsJUnit3Test.class);
    addTestSuite(OtherTest1.class);
    addTestSuite(OtherTest2.class);
  }
}

Можно запустить один и тот же тест несколько раз. Для этого используем RepeatedTest:
public class StringUtilsJUnit3RepeatedTest extends RepeatedTest {
  public StringUtilsJUnit3RepeatedTest() {
    super(new StringUtilsJUnit3Test(), 100);
  }
}

Наследуя тест-класс от ExceptionTestCase, можно проверить что-либо на выброс исключения:
public class StringUtilsJUnit3ExceptionTest extends ExceptionTestCase {
  public StringUtilsJUnit3ExceptionTest(final String name) {
    super(name, NullPointerException.class);
  }

  public void testToHexString() {
    StringUtils.toHexString(null);
  }
}

Как видно из примеров все довольно просто, ничего лишнего, минимум нужный для тестирования(хотя недостает и некоторых нужных вещей).

JUnit 4


Здесь была добавлена поддержка новых возможностей из Java 5, тесты теперь могут быть объявлены с помощью аннотаций. При этом существует обратная совместимость с предыдущей версией фреймворка, практически все рассмотренные выше примеры будут работать и здесь(за исключением RepeatedTest, его нет в новой версии).

Итак, что же поменялось?

Основные аннотации


Рассмотрим тот же пример, но уже используя новые возможности:
public class StringUtilsJUnit4Test extends Assert {
  private final Map<String, byte[]> toHexStringData = new HashMap<String, byte[]>();

  @Before
  public static void setUpToHexStringData() {
    toHexStringData.put("", new byte[0]);
    toHexStringData.put("01020d112d7f", new byte[] { 1, 2, 13, 17, 45, 127 });
    toHexStringData.put("00fff21180", new byte[] { 0, -1, -14, 17, -128 });
    //...
  }

  @After
  public static void tearDownToHexStringData() {
    toHexStringData.clear();
  }

  @Test
  public void testToHexString() {
    for (Map.Entry<String, byte[]> entry : toHexStringData.entrySet()) {
      final byte[] testData = entry.getValue();
      final String expected = entry.getKey();
      final String actual = StringUtils.toHexString(testData);
      assertEquals(expected, actual);
    }
  }
}

Что мы здесь видим?
  • Для упрощения работы я предпочитаю наследоваться от класса Assert, хотя это необязательно.
  • Аннотация @Before обозначает методы, которые будут вызваны до исполнения теста, методы должны быть public void. Здесь обычно размещаются предустановки для теста, в нашем случае это генерация тестовых данных (метод setUpToHexStringData).
  • Аннотация @BeforeClass обозначает методы, которые будут вызваны до создания экземпляра тест-класса, методы должны быть public static void. Имеет смысл размещать предустановки для теста в случае, когда класс содержит несколько тестов использующих различные предустановки, либо когда несколько тестов используют одни и те же данные, чтобы не тратить время на их создание для каждого теста.
  • Аннотация @After обозначает методы, которые будут вызваны после выполнения теста, методы должны быть public void. Здесь размещаются операции освобождения ресурсов после теста, в нашем случае — очистка тестовых данных (метод tearDownToHexStringData).
  • Аннотация @AfterClass связана по смыслу с @BeforeClass, но выполняет методы после теста, как и в случае с @BeforeClass, методы должны быть public static void.
  • Аннотация @Test обозначает тестовые методы. Как и ранее, эти методы должны быть public void. Здесь размещаются сами проверки. Кроме того, у данной аннотации есть два параметра, expected — задает ожидаемое исключение и timeout — задает время, по истечению которого тест считается провалившимся.

  @Test(expected = NullPointerException.class)
  public void testToHexStringWrong() {
    StringUtils.toHexString(null);
  }

  @Test(timeout = 1000)
  public void infinity() {
    while (true);
  }

Если какой-либо тест по какой-либо серьезной причине нужно отключить(например, этот тест постоянно валится, но его исправление отложено до светлого будущего) его можно зааннотировать @Ignore. Также, если поместить эту аннотацию на класс, то все тесты в этом классе будут отключены.
  @Ignore
  @Test(timeout = 1000)
  public void infinity() {
    while (true);
  }

Правила


Кроме всего вышеперечисленного есть довольно интересная вещь — правила. Правила это некое подобие утилит для тестов, которые добавляют функционал до и после выполнения теста.

Например, есть встроенные правила для задания таймаута для теста(Timeout), для задания ожидаемых исключений(ExpectedException), для работы с временными файлами(TemporaryFolder) и д.р. Для объявления правила необходимо создать public не static поле типа производного от MethodRule и зааннотировать его с помощью Rule.
public class OtherJUnit4Test {

  @Rule
  public final TemporaryFolder folder = new TemporaryFolder();

  @Rule
  public final Timeout timeout = new Timeout(1000);

  @Rule
  public final ExpectedException thrown = ExpectedException.none();

  @Ignore
  @Test
  public void anotherInfinity() {
    while (true);
  }

  @Test
  public void testFileWriting() throws IOException {
    final File log = folder.newFile("debug.log");
    final FileWriter logWriter = new FileWriter(log);
    logWriter.append("Hello, ");
    logWriter.append("World!!!");
    logWriter.flush();
    logWriter.close();
  }

  @Test
  public void testExpectedException() throws IOException {
    thrown.expect(NullPointerException.class);
    StringUtils.toHexString(null);
  }
}

Также в сети можно найти и другие варианты использования. Например, здесь рассмотрена возможность параллельного запуска теста.

Запускалки


Но и на этом возможности фреймворка не заканчиваются. То, как запускается тест, тоже может быть сконфигурировано с помощью @RunWith. При этом класс, указанный в аннотации должен наследоваться от Runner. Рассмотрим запускалки, идущие в комплекте с самим фреймворком.

JUnit4 — запускалка по умолчанию, как понятно из названия, предназначена для запуска JUnit 4 тестов.

JUnit38ClassRunner предназначен для запуска тестов, написанных с использованием JUnit 3.

SuiteMethod либо AllTests тоже предназначены для запуска JUnit 3 тестов. В отличие от предыдущей запускалки, в эту передается класс со статическим методом suite возвращающим тест(последовательность всех тестов).

Suite — эквивалент предыдущего, только для JUnit 4 тестов. Для настройки запускаемых тестов используется аннотация @SuiteClasses.
@Suite.SuiteClasses( { OtherJUnit4Test.class, StringUtilsJUnit4Test.class })
@RunWith(Suite.class)
public class JUnit4TestSuite {
}

Enclosed — то же, что и предыдущий вариант, но вместо настройки с помощью аннотации используются все внутренние классы.

Categories — попытка организовать тесты в категории(группы). Для этого тестам задается категория с помощью @Category, затем настраиваются запускаемые категории тестов в сюите. Это может выглядеть так:
public class StringUtilsJUnit4CategoriesTest extends Assert {
  //...

  @Category(Unit.class)
  @Test
  public void testIsEmpty() {
    //...
  }

  //...
}

@RunWith(Categories.class)
@Categories.IncludeCategory(Unit.class)
@Suite.SuiteClasses( { OtherJUnit4Test.class, StringUtilsJUnit4CategoriesTest.class })
public class JUnit4TestSuite {
}

Parameterized — довольно интересная запускалка, позволяет писать параметризированные тесты. Для этого в тест-классе объявляется статический метод возвращающий список данных, которые затем будут использованы в качестве аргументов конструктора класса.
@RunWith(Parameterized.class)
public class StringUtilsJUnit4ParameterizedTest extends Assert {
  private final CharSequence testData;
  private final boolean expected;

  public StringUtilsJUnit4ParameterizedTest(final CharSequence testData, final boolean expected) {
    this.testData = testData;
    this.expected = expected;
  }

  @Test
  public void testIsEmpty() {
    final boolean actual = StringUtils.isEmpty(testData);
    assertEquals(expected, actual);
  }

  @Parameterized.Parameters
  public static List<Object[]> isEmptyData() {
    return Arrays.asList(new Object[][] {
      { null, true },
      { "", true },
      { " ", false },
      { "some string", false },
    });
  }
}

Theories — чем-то схожа с предыдущей, но параметризирует тестовый метод, а не конструктор. Данные помечаются с помощью @DataPoints и @DataPoint, тестовый метод — с помощью @Theory. Тест использующий этот функционал будет выглядеть примерно так:
@RunWith(Theories.class)
public class StringUtilsJUnit4TheoryTest extends Assert {

  @DataPoints
  public static Object[][] isEmptyData = new Object[][] {
      { "", true },
      { " ", false },
      { "some string", false },
  };

  @DataPoint
  public static Object[] nullData = new Object[] { null, true };

  @Theory
  public void testEmpty(final Object... testData) {
    final boolean actual = StringUtils.isEmpty((CharSequence) testData[0]);
    assertEquals(testData[1], actual);
  }
}

Как и в случае с правилами, в сети можно найти и другие варианты использования. Например, здесь рассмотрена та же возможность паралельного запуска теста, но с использованием запускалок.

Вывод


Это, конечно же, не все, что можно было сказать по JUnit-у, но я старался вкратце и по делу. Как видно, фреймворк достаточно прост в использовании, дополнительных возможностей немного, но есть возможность расширения с помощью правил и запускалок. Но несмотря на все это я все же предпочитаю TestNG с его мощным функционалом, о котором и расскажу в следующей статье.

Примеры можно найти на гитхабе.

Литература


Иван Холопик @sody
карма
50,0
рейтинг 0,0
Говнокодер
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (33)

  • 0
    Ооо, спасибо. Много полезного.

    А Вы наследуетесь от Assert просто чтобы импорты не писать?
    • +9
      Странно, зачем? Есть же static-импорты.
  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Лично я считаю назвать метод тестирующий заполнение адреса как testPopulateAddress более наглядно чем просто populateAddress — здесь приставка test несет смысловую нагрузку.
      • +2
        Смысловую нагрузку уже несет @Test, поэтому не очень понятно, зачем повторять это слово в имени метода кроме как по привычке или для совместимости.
      • НЛО прилетело и опубликовало эту надпись здесь
        • 0
          В данном примере приставка test говорит, что метод именно тестирует заполнение адреса и, как прочая дополнительная информация в названии, «говорит, что конкретно там происходит». Без нее можно подумать, что метод используется только для заполнения адреса. И даже с дополнительной смысловой нагрузкой (как, к примеру, в посте ниже populatesAdressWhenHostIsGiven) при просмотре листинга методов в IDE (тем более если их много) нельзя точно сказать что это за метод — вспомогательный для заполнения адреса или же метод, который тестирует заполнение адреса.
          • НЛО прилетело и опубликовало эту надпись здесь
            • 0
              Спасибо, сейчас же и посмотрю (:
      • +3
        что testPopulateAdress, что populateAdress — оба варианта так себе. Слово тест в начале никак его не красит. А вот название, к примеру, populatesAdressWhenHostIsGiven — уже ближе к правде будет.
  • +1
    Я вообще запускаюсь с помощью спринговой запускалки. Очень полезная вещь с учетом того что очень много проектов пишется с учетом конфигурирования на спринге. Очень легко конфигурируется эта связка.
  • +2
    Ну если уже захотелось написать тесты, то использовать надо TestNG. Он годиться как для юнитестов, так и для integration and system tests. TestNG может запускать тесты в разных threads, устанавливать зависимости между тестами и параметризация сделана намного глубже чем junit4.
    • 0
      вот написал бы кто-н подробно, чем ТестНГ лучше — было бы интересно почитать
      пока что про потоки, зависимости и параметризации не очень убедительно
      • 0
        TestNG современный testing framework. JUnit старай рабочая лошадка на котором написано миллионы юнитестов, но JUnit 3 морально устарел, а JUnit 4 это жалкая попытка добавить отсутствующий функционал.
        В JUnit каждый юнитест абсолютно не зависим, что в реальной жизни не всегда удобно, в результате разработчики хранят общий контекст в static variables. В TestNG есть зависимость и порядок выполнения между юнитестами а также возможность передавать контекст из теста в тест. Советую прочитать testng.org/doc/documentation-main.html — всего одна страница.
        • +1
          Я не могу придумать ни единого валидного случая, когда мне бы захотелось сделать тесты зависимыми друг от друга.
          • 0
            Это только в идеальных условиях тесты могут быть независимые или простые юнитесты. А integration или system тесты имеют определенный порядок, так как проверяют сложные сценарии. Даже в обычных юнитестах скажем для DAO Layer проще проверять CRUD операции последовательно Create / Update / Remove в разных тест мотодах. Иначе 90% кода в каждом юнитесте уходит на подготовку данных для теста. Я исключаю крайности когда все Assert-ы находятся в одном @Test методе. Best Practice чтобы было много мелких методов.
          • 0
            Пример — в статье рассмотрена утилита, проверяющая строку на пустоту. Допустим есть сервис, который что-то делает, что-то таинственное, использует при этом эту утилиту, где то в коде использует проверку на пустоту. Естественно, если не будет работать утилита, то не будет работать и сервис, тест сервиса зависит от теста утилиты.
  • 0
    Спасибо большое, но мне как новичку хотелось бы больше практики. То есть:

    В экслипсе надо заранее создать отдельный source folder для тестов чтобы не мешать их с кодом, так? Я всё правильно делаю? А как ему объяснить, что я не хочу экспортировать тесты вместе с кодом? В Order and Export папка с тестами не убирается, при экспорте тоже настроек нет. Или мне нужно вот только для этого срочно изучать Ant?
    А как попросить экслипса прогонять тесты перед запуском приложения из него же? Ведь они для этого нужны и этого мы хотели, чтобы сразу видеть что сломалось, разве не так?

    В моём методе есть обычный assert. Попробовал его потестировать с @Test(expected = AssertionError.class)
    Не проходит… говорит java.lang.AssertionError: Expected java.lang.AssertionError. То есть AssertionError не совместим с JUnit? А какие ещё исключения/ограничения? Их много?
    Если поставить -ea в запускалку JUnit'а, то вообще все тесты вот так валятся… о_О

    Про TestNG напишите пожалуйста :)
    • +1
      Да, в планах продолжения по TestNG, Spock Framework, инструментарий maven-а, и д.р. Материала много, было бы время :)
      • 0
        Эээ, а может Вы как старший товарищ ещё и на вопросы про эклипс ответите? Если Вы им пользуетесь, то наверняка ж знаете, а я всё нужные кнопки найти не могу…
        • 0
          Хотя бы как тесты автоматом при запуске прогонять… А то толку от них не будет жн, если их не гонять.
          • 0
            В Eclipse тесты автоматом обычно не гоняют. Что поменял — то и запустил.
            Есть плагины, которые умеют запускать автоматом: Infinitest и JUnitMax. Здесь их сравнение:
            www.benrady.com/2009/04/comparing-infinitest-and-junitmax.html
        • 0
          Извините, с эклипсом мало знаком, но тут другое, можете попытаться отлавливать Throwable ;)
          Насчет прогона тестов, чаще всего пользуюсь мавеном из терминала, иногда дебаг под идеей.
    • 0
      > В экслипсе надо заранее создать отдельный source folder для тестов чтобы не мешать их с кодом, так? Я всё правильно делаю? А как ему объяснить, что я не хочу экспортировать тесты вместе с кодом? В Order and Export папка с тестами не убирается, при экспорте тоже настроек нет. Или мне нужно вот только для этого срочно изучать Ant?
      А как попросить экслипса прогонять тесты перед запуском приложения из него же? Ведь они для этого нужны и этого мы хотели, чтобы сразу видеть что сломалось, разве не так?

      Попробуйте изучить maven :) Потратите два дня максимум, зато потом до конца жизни будете радоваться. Думаю, в maven есть все ответы на ваши вопросы.

      > В моём методе есть обычный assert. Попробовал его потестировать с @Test(expected = AssertionError.class)
      Не проходит… говорит java.lang.AssertionError: Expected java.lang.AssertionError. То есть AssertionError не совместим с JUnit? А какие ещё исключения/ограничения? Их много?
      Если поставить -ea в запускалку JUnit'а, то вообще все тесты вот так валятся… о_О

      JUnit поддерживает обычные assert'ы. Уберите expected и всегда включайте ключик -ea. Если assert нарушится, то тест упадет и JUnit это зафиксирует.
  • +2
    Требую добавления в статью обзора stub/mock-фреймворков: без них unit-тестирование вовсе не unit!

    p.s. А в остальном отлично;)
    • 0
      +1. В контексте познавания JUnit необходимо посмотреть на EasyMock например.
    • +1
      Обожаю easymock. В очереди easymock, jmock, mockito.
      • 0
        Советую еще Powermock
    • 0
      Про Mockito уже была статья на хабре:
      habrahabr.ru/blogs/java/72617/
  • 0
    Вроде бы в книге «Продутивный Программист» Нила Форда по поводу имен тестовых методов я прочитал хороший совет, которым пользуюсь. Суть в том, что для имен тестовых методов можно сделать исключение и писать их через подчеркивание, например:

    publiс void check_if_empty_list_is_not_raised_exception() {...}

    Так имена тестовых методов становятся гораздо более читаемыми.
  • 0
    Хорошая статья. Могу еще добавить от себя пару фишек JUnit.

    1. Классы из пакета org.hamcrest, в частности Matcher и его подклассы. Позволяют много чего.
    Например,
    assertThat(obj, instanceOf(A.class)) — проверка, что obj — экземпляр класса A
    assertThat(list.size(), not(0)) — проверка, что list.size() не равен нулю

    2. Из правил могу добавить ErrorCollector — позволяет накапливать ошибки в ассертах. Полезно, когда нужно видеть сразу все места, где упал тест, а не только первый assert

    3. Класс JUnit4TestAdapter — позволяет запускать JUnit4 тесты в JUnit3 стиле, т.е. через TestCase. Полезно, когда среда тестирования не поддерживает JUnit4, например JUnitEE или Apache Cactus.

    Пример:

    public class SampleTestSuite
    {
       public static Test suite()
       {
         TestSuite suite = new TestSuite("Sample Tests");

         suite.addTest(new JUnit4TestAdapter(SampleTest1.class));
         suite.addTest(new JUnit4TestAdapter(SampleTest2.class));

         return suite;
       }
    }
  • 0
  • 0
    создать Suite с помощью JUnit — редкий гемморой. Пришлось даже написать небольшой велосипед для этого

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.