Pull to refresh

Тестирование в Java. JUnit

Reading time 8 min
Views 530K

Сегодня все большую популярность приобретает 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 с его мощным функционалом, о котором и расскажу в следующей статье.

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

Литература


Tags:
Hubs:
+44
Comments 34
Comments Comments 34

Articles