
Наверняка все знакомы с таким понятием как test-driven development(TDD). Наряду с ним также существует такое понятие, как data-driven testing(DDT, не в обиду Шевчуку) — техника написания тестов, при которой данные для тестов хранятся отдельно от самих тестов. Они могут храниться в базе данных, файле, генерироваться во время исполнения теста. Это очень удобно, так как один и тот же функционал тестируется на различных наборах данных, при этом добавление, удаление или изменение этих данных максимально упрощено.
В предыдущей статье я рассмотрел возможности JUnit-а. Там примерами такого рода подхода могут служить запускалки Parameterized и Theories, в обоих случаях один тест-класс может содержать только один такой параметризированный тест(в случае Parameterized несколько, но все они будут использовать одни и те же данные).
В этой статье я заострю внимание на тестовом фреймворке TestNG. Многие уже слышали это название, и перейдя на него, вряд ли желают вернуться к JUnit-у(хотя это только предположение).
Основные возможности
Итак, что же здесь есть? Как и в JUnit 4 тесты описываются с помощью аннотаций, также поддерживаются тесты, написанные на JUnit 3. Есть возможность вместо аннотаций использовать доклет.
Для начала рассмотрим иерархию тестов. Все тесты принадлежат к какой-либо последовательности тестов(сюите), включают в себя некоторое количество классов, каждый из которых может состоять из нескольких тестовых методов. При этом классы и тестовые методы могут принадлежать к определенной группе. Наглядно это выглядит так:
+- suite/ +- test0/ | +- class0/ | | +- method0(integration group)/ | | +- method1(functional group)/ | | +- method2/ | +- class1 | +- method3(optional group)/ +- test1/ +- class3(optional group, integration group)/ +- method4/
У каждого участника этой иерархии могут иметься before и after конфигураторы. Запускается это все в таком порядке:
+- before suite/ +- before group/ +- before test/ +- before class/ +- before method/ +- test/ +- after method/ ... +- after class/ ... +- after test/ ... +- after group/ ... +- after suite/
Теперь поподробнее о самих тестах. Рассмотрим пример. Утилита для работы с локалями, умеет парсить из строки, а также искать кандидаты(en_US -> en_US, en, root):
public abstract class LocaleUtils { /** * Root locale fix for java 1.5 */ public static final Locale ROOT_LOCALE = new Locale(""); private static final String LOCALE_SEPARATOR = "_"; public static Locale parseLocale(final String value) { if (value != null) { final StringTokenizer tokens = new StringTokenizer(value, LOCALE_SEPARATOR); final String language = tokens.hasMoreTokens() ? tokens.nextToken() : ""; final String country = tokens.hasMoreTokens() ? tokens.nextToken() : ""; String variant = ""; String sep = ""; while (tokens.hasMoreTokens()) { variant += sep + tokens.nextToken(); sep = LOCALE_SEPARATOR; } return new Locale(language, country, variant); } return null; } public static List<Locale> getCandidateLocales(final Locale locale) { final List<Locale> locales = new ArrayList<Locale>(); if (locale != null) { final String language = locale.getLanguage(); final String country = locale.getCountry(); final String variant = locale.getVariant(); if (variant.length() > 0) { locales.add(locale); } if (country.length() > 0) { locales.add((locales.size() == 0) ? locale : new Locale(language, country)); } if (language.length() > 0) { locales.add((locales.size() == 0) ? locale : new Locale(language)); } } locales.add(ROOT_LOCALE); return locales; } }
Напишем к ней тест в стиле JUnit-a(не стоит рассматривать данный пример как руководство к написанию тестов на TestNG):
public class LocaleUtilsOldStyleTest extends Assert { private final Map<String, Locale> parseLocaleData = new HashMap<String, Locale>(); @BeforeClass private void setUp() { parseLocaleData.put(null, null); parseLocaleData.put("", LocaleUtils.ROOT_LOCALE); parseLocaleData.put("en", Locale.ENGLISH); parseLocaleData.put("en_US", Locale.US); parseLocaleData.put("en_GB", Locale.UK); parseLocaleData.put("ru", new Locale("ru")); parseLocaleData.put("ru_RU_xxx", new Locale("ru", "RU", "xxx")); } @AfterTest void tearDown() { parseLocaleData.clear(); } @Test public void testParseLocale() { for (Map.Entry<String, Locale> entry : parseLocaleData.entrySet()) { final Locale actual = LocaleUtils.parseLocale(entry.getKey()); final Locale expected = entry.getValue(); assertEquals(actual, expected); } } }
Что здесь есть?
- Как уже было сказано в предыдущей статье я предпочитаю наследовать тест-класс от Assert, это можно заменить статическим импортом, либо использованием класса напрямую(Assert.assertEquals(...)). В реальной системе удобнее всего наследовать тест от какого-либо базового класса, который в свою очередь наследовать от Assert, это дает возможность переопределять либо добавлять необходимые методы. Внимание: в отличие от такого же класса в JUnit здесь во все методы актуальное значение передается первым, ожидаемое вторым(в JUnit наоборот).
- Аннотации @BeforeSuite, @AfterSuite обозначают методы, которые исполняются единожды до/после исполнения всех тестов. Здесь удобно располагать какие-либо тяжелые настройки общие для всех тестов, например, здесь можно создать пул соединений с базой данных.
- Аннотации @BeforeTest, @AfterTest обозначают методы, которые исполняются единожды до/после исполнения теста(тот, который включает в себя тестовые классы, не путать с тестовыми методами). Здесь можно хранить настройки какой-либо группы взаимосвязанных сервисов, либо одного сервиса, если он тестируется несколькими тест-классами.
- Аннотации @BeforeClass, @AfterClass обозначают методы, которые исполняются единожды до/после исполнения всех тестов в классе, идентичны предыдущим, но применимы к тест-классам. Наиболее применим для тестирования какого-то определенного сервиса, который не меняет свое состояние в результате теста.
- Аннотации @BeforeMethod, @AfterMethod обозначают методы, которые исполняются каждый раз до/после исполнения тестового метода. Здесь удобно хранить настройки для определенного бина или сервиса, если он не меняет свое состояние в результате теста.
- Аннотации @BeforeGroups, @AfterGroups обозначает методы, которые исполняются до/после первого/последнего теста принадлежащего к заданным группам.
- Аннотация @Test обозначает сами тесты. Здесь размещаются проверки. Также применима к классам
У всех этих аннотаций есть следующие параметры:
- enabled — можно временно отключить, установив значение в false
- groups — обозначает, для каких групп будет исполнен
- inheritGroups — если true(а по умолчанию именно так), метод будет наследовать группы от тест-класса
- timeOut — время, после которого метод «свалится» и потянет за собой все зависимые от него тесты
- description — название, используемое в отчете
- dependsOnMethods — методы, от которых зависит, сначала будут выполнены они, а затем данный метод
- dependsOnGroups — группы, от которых зависит
- alwaysRun — если установить в true, будет вызываться всегда независимо от того, к каким группам принадлежит, не применим к @BeforeGroups, @AfterGroups
Параметризированные тесты
Напишем этот же тест другим способом:
public class LocaleUtilsTest extends Assert { @DataProvider public Object[][] parseLocaleData() { return new Object[][]{ {null, null}, {"", LocaleUtils.ROOT_LOCALE}, {"en", Locale.ENGLISH}, {"en_US", Locale.US}, {"en_GB", Locale.UK}, {"ru", new Locale("ru")}, {"ru_RU_some_variant", new Locale("ru", "RU", "some_variant")}, }; } @Test(dataProvider = "parseLocaleData") public void testParseLocale(String locale, Locale expected) { final Locale actual = LocaleUtils.parseLocale(locale); assertEquals(actual, expected); } }
Проще? Конечно, данные хранятся отдельно от самого теста. Удобно? Конечно, можно добавлять тесты, добавляя всего лишь строчку в метод parseLocaleData.
Итак, как это работает?
- Объявляем тестовый метод со всеми нужными ему параметрами, например входные и ожидаемые данные. В нашем случае это строка, которую нужно распарсить в локаль и ожидаемая в результате локаль.
- Объявляем дата провайдер, хранилище данных для теста. Обычно это метод, возвращающий Object[][] либо Iterator<Object[]>, содержащий список параметров для определенного теста, например {«en_US», Locale.US}. Этот метод должен быть зааннотирован с помощью @DataProvider, в самом тесте он объявляется с помощью параметра dataProvider в аннотации @Test. Также можно указать имя(параметр name), если не указывать в качестве имени будет использоваться название метода.
Еще один пример, теперь разнесем данные и логику теста в разные классы:
public class LocaleUtilsTestData { @DataProvider(name = "getCandidateLocalesData") public static Object[][] getCandidateLocalesData() { return new Object[][]{ {null, Arrays.asList(LocaleUtils.ROOT_LOCALE)}, {LocaleUtils.ROOT_LOCALE, Arrays.asList(LocaleUtils.ROOT_LOCALE)}, {Locale.ENGLISH, Arrays.asList(Locale.ENGLISH, LocaleUtils.ROOT_LOCALE)}, {Locale.US, Arrays.asList(Locale.US, Locale.ENGLISH, LocaleUtils.ROOT_LOCALE)}, {new Locale("en", "US", "xxx"), Arrays.asList( new Locale("en", "US", "xxx"), Locale.US, Locale.ENGLISH, LocaleUtils.ROOT_LOCALE) }, }; } } public class LocaleUtilsTest extends Assert { // other tests @Test(dataProvider = "getCandidateLocalesData", dataProviderClass = LocaleUtilsTestData.class) public void testGetCandidateLocales(Locale locale, List<Locale> expected) { final List<Locale> actual = LocaleUtils.getCandidateLocales(locale); assertEquals(actual, expected); } }
В этом случае задаются параметры dataProviderClass и dataProvider. Метод, возвращающий тестовые данные должен быть static.
Кроме описанного выше есть еще один способ параметризировать тесты. Нужный метод аннотируется с помощью @Parameters, где указываются имена всех необходимых параметров. Некоторые из параметров можно зааннотировать с помощью @Optional с указанием значения по умолчанию(если не указать, то будут использоваться значения по умолчанию для примитивов, либо null для всех остальных типов). Значения параметров хранятся в конфигурации TestNG(которая будет рассмотрена позже). Пример:
public class ParameterizedTest extends Assert { private DataSource dataSource; @Parameters({"driver", "url", "username", "password"}) @BeforeClass public void setUpDataSource(String driver, String url, @Optional("sa") String username, @Optional String password) { // create datasource dataSource = ... } @Test public void testOptionalData() throws SQLException { dataSource.getConnection(); // do some staff } }
В данном случае метод setUpDataSource будет принимать в качестве параметров настройки соединения с БД, причем параметры username и password опциональны, с заданными значениями по умолчанию. Очень удобно использовать с данными, общими для всех тестов(ну или почти всех), например, как в примере настройки соединения с БД.
Ну и в завершение следует сказать пару слов о фабриках, которые позволяют создавать тесты динамически. Также, как и сами тесты, могут быть параметризированы с помощью @DataProvider либо @Parameters:
public class FactoryTest { @DataProvider public Object[][] tablesData() { return new Object[][] { {"FIRST_TABLE"}, {"SECOND_TABLE"}, {"THIRD_TABLE"}, }; } @Factory(dataProvider = "tablesData") public Object[] createTest(String table) { return new Object[] { new GenericTableTest(table) }; } } public class GenericTableTest extends Assert { private final String table; public GenericTableTest(final String table) { this.table = table; } @Test public void testTable() { System.out.println(table); // do some testing staff here } }
Вариант с @Parameters:
public class FactoryTest { @Parameters("table") @Factory public Object[] createParameterizedTest(@Optional("SOME_TABLE") String table) { return new Object[] { new GenericTableTest(table) }; } }
Многопоточность
Нужно проверить, как поведет себя приложение во многопоточном окружении? Можно сделать так, чтобы тесты исполнялись одновременно из нескольких потоков:
public class ConcurrencyTest extends Assert { private Map<String, String> data; @BeforeClass void setUp() throws Exception { data = new HashMap<String, String>(); } @AfterClass void tearDown() throws Exception { data = null; } @Test(threadPoolSize = 30, invocationCount = 100, invocationTimeOut = 10000) public void testMapOperations() throws Exception { data.put("1", "111"); data.put("2", "111"); data.put("3", "111"); data.put("4", "111"); data.put("5", "111"); data.put("6", "111"); data.put("7", "111"); for (Map.Entry<String, String> entry : data.entrySet()) { System.out.println(entry); } data.clear(); } @Test(singleThreaded = true, invocationCount = 100, invocationTimeOut = 10000) public void testMapOperationsSafe() throws Exception { data.put("1", "111"); data.put("2", "111"); data.put("3", "111"); data.put("4", "111"); data.put("5", "111"); data.put("6", "111"); data.put("7", "111"); for (Map.Entry<String, String> entry : data.entrySet()) { System.out.println(entry); } data.clear(); } }
- threadPoolSize определяет максимальное количество потоков используемое для тестов.
- singleThreaded если установлен в true все тесты будут запущены в одном потоке.
- invocationCount определяет количество запусков теста.
- invocationTimeOut определяет общее время всех запусков теста, после которого тест считается провалившемся.
Еще можно установить параметр parallel у дата провайдера в true, тогда тесты для каждого набора данных будут запущены паралельно, в отдельном потоке:
public class ConcurrencyTest extends Assert { // some staff here @DataProvider(parallel = true) public Object[][] concurrencyData() { return new Object[][] { {"1", "2"}, {"3", "4"}, {"5", "6"}, {"7", "8"}, {"9", "10"}, {"11", "12"}, {"13", "14"}, {"15", "16"}, {"17", "18"}, {"19", "20"}, }; } @Test(dataProvider = "concurrencyData") public void testParallelData(String first, String second) { final Thread thread = Thread.currentThread(); System.out.printf("#%d %s: %s : %s", thread.getId(), thread.getName(), first, second); System.out.println(); } }
Данный тест будет выводить нечто вроде:
#16 pool-1-thread-3: 5 : 6 #19 pool-1-thread-6: 11 : 12 #14 pool-1-thread-1: 1 : 2 #22 pool-1-thread-9: 17 : 18 #20 pool-1-thread-7: 13 : 14 #18 pool-1-thread-5: 9 : 10 #15 pool-1-thread-2: 3 : 4 #17 pool-1-thread-4: 7 : 8 #21 pool-1-thread-8: 15 : 16 #23 pool-1-thread-10: 19 : 20
Без этого параметра будет что-то вроде:
#1 main: 1 : 2 #1 main: 3 : 4 #1 main: 5 : 6 #1 main: 7 : 8 #1 main: 9 : 10 #1 main: 11 : 12 #1 main: 13 : 14 #1 main: 15 : 16 #1 main: 17 : 18 #1 main: 19 : 20
Дополнительные возможности
Кроме всего описанного есть и другие возможности, например для проверки выброса исключений(очень удобно использовать для тестов на неправильных данных):
public class ExceptionTest { @DataProvider public Object[][] wrongData() { return new Object[][] { {"Hello, World!!!"}, {"0x245"}, {"1798237199878129387197238"}, }; } @Test(dataProvider = "wrongData", expectedExceptions = NumberFormatException.class, expectedExceptionsMessageRegExp = "^For input string: \"(.*)\"$") public void testParse(String data) { Integer.parseInt(data); } }
- expectedExceptions задает варианты ожидаемых исключений, если они не выбрасываются, тест считается провалившемся.
- expectedExceptionsMessageRegExp то же что и предыдущий параметр, но задает regexp для сообщения об ошибке.
public class PrioritiesTest extends Assert { private boolean firstTestExecuted; @BeforeClass public void setUp() throws Exception { firstTestExecuted = false; } @Test(priority = 0) public void first() { assertFalse(firstTestExecuted); firstTestExecuted = true; } @Test(priority = 1) public void second() { assertTrue(firstTestExecuted); } }
- priority определяет приоритет теста внутри класса, чем меньше, тем раньше будет выполнен.
Похожее поведение будет наблюдаться также если указать зависимости у теста, например, добавим в наш тест:
public class PrioritiesTest extends Assert { // some staff @Test(dependsOnMethods = {"first"}) public void third() { assertTrue(firstTestExecuted); } // some staff }
Обычно это удобно, когда один тест зависит от другого, например, Утилита1 использует Утилиту0, если Утилита0 работает неправильно, то нет смысла тестировать Утилиту1. С другой стороны зависимости также удобно использовать в @Before, @After методах, особенно для связи базового теста с наследующимся, причем иногда бывает необходимо сделать так, чтобы даже если метод A свалился, а метод B зависит от него, метод B все равно вызывался. В этом случае устанавливаем параметр alwaysRun в true.
Внедрение зависимостей
Хочу порадовать любителей фреймворка от «корпорации добра» Guice. В TestNG есть встроенная поддержка последнего. Выглядит это так:
public class GuiceModule extends AbstractModule { @Override protected void configure() { bind(String.class).annotatedWith(Names.named("guice-string-0")).toInstance("Hello, "); } @Named("guice-string-1") @Inject @Singleton @Provides public String provideGuiceString() { return "World!!!"; } } @Guice(modules = {GuiceModule.class}) public class GuiceTest extends Assert { @Inject @Named("guice-string-0") private String word0; @Inject @Named("guice-string-1") private String word1; @Test public void testService() { final String actual = word0 + word1; assertEquals(actual, "Hello, World!!!"); } }
Все, что надо — зааннотировать нужный класс с помощью @Guice и указать в параметре modules все необходимые guice-модули. Далее в тест классе можно уже использовать внедрение зависимостей, используя @Inject.
Добавлю еще, что любителям других подобных фреймворков не стоит расстраиваться, так как у них обычно есть своя поддержка TestNG, например, у Spring-а.
Расширение функционала
Расширение функционала может быть реализовано с помощью механизма слушателей. Поддерживаются следующие типы слушателей:
- IAnnotationTransformer, IAnnotationTransformer2 — позволяют переопределять настройки теста, например, количество потоков для запуска теста, таймаут, ожидаемое исключение:
public class ExpectTransformer implements IAnnotationTransformer { public void transform(ITestAnnotation annotation, Class testClass, Constructor testConstructor, Method testMethod) { if (testMethod.getName().startsWith("expect")) { annotation.setExpectedExceptions(new Class[] {Exception.class}); } } }
Данный пример будет ожидать выброс исключения от тест-методов, начинающихся с expect. - IHookable — позволяет переопределить тест-метод или по возможности пропустить, в туториале к TestNG приводится пример с JAAS.
- IInvokedMethodListener, IInvokedMethodListener2 — похож на предыдущий слушатель, но исполняет код до и после исполнения тест-метода
- IMethodInterceptor — позволяет изменять порядок запуска тестов(применим только к тестам, которые независимы от других тестов). В туториале есть хороший пример
- IReporter — позволяет расширить функционал, выполняемый после выполнения всех тестов, обычно этот функционал связан с генерацией отчетов об ошибках и т.д. Таким образом можно реализовать свой механизм отчетов
- ITestListener — слушатель, который может обрабатывать большинство событий от тест-метода, например, start, finish, success, failure
- ISuiteListener — похож на предыдущий, но для сюит, получает только события start и finish
Конфигурация
Теперь перейдем к конфигурации тестов. Простейший способ запустить тесты выглядит примерно так:
final TestNG testNG = new TestNG(true); testNG.setTestClasses(new Class[] {SuperMegaTest.class}); testNG.setExcludedGroups("optional"); testNG.run();
Но чаще всего для запуска тестов используется XML либо YAML конфигурация. XML конфигурация выглядит примерно так:
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd"> <suite name="Test suite" parallel="classes" thread-count="10"> <test name="Default tests" verbose="1" annotations="JDK" thread-count="10" parallel="classes"> <parameter name="driver" value="com.mysql.jdbc.Driver"/> <parameter name="url" value="jdbc:mysql://localhost:3306/db"/> <groups> <run> <exclude name="integration"/> </run> </groups> <packages> <package name="com.example.*"/> </packages> </test> <test name="Integration tests" annotations="JDK"> <groups> <run> <include name="integration"/> </run> </groups> <packages> <package name="com.example.*"/> </packages> </test> </suite>
Аналогичная YAML конфигурация:
name: YAML Test suite parallel: classes threadCount: 10 tests: - name: Default tests parameters: driver: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/db excludedGroups: [ integration ] packages: - com.example.* - name: Integration tests parameters: driver: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/db includedGroups: [ integration ] packages: - com.example.*
Тогда для запуска тестов нужно будет сделать следующее:
final TestNG testNG = new TestNG(true); //final Parser parser = new Parser("testing/testing-testng/src/test/resources/testng.xml"); final Parser parser = new Parser("testing/testing-testng/src/test/resources/testng.yaml"); final List<XmlSuite> suites = parser.parseToList(); testNG.setXmlSuites(suites); testNG.run();
Хотя, наверное, программный запуск тестов это излишне, т.к. для этого можно использовать возможности IDE.
Вернемся к самой конфигурации. На самом верхнем уровне настраивается последовательность тестов(сюита). Может принимать следующие параметры:
- name — название используемое в отчете
- thread-count — количество потоков используемое для запуска тестов
- data-provider-thread-count — количество потоков используемое для передачи данных из дата-провайдеров в сами тесты для параллельных дата провайдеров(@DataProvider(parallel = true))
- parallel — может принимать следующие значения:
- methods — тестовые методы будут запущены в разных потоках, нужно быть осторожным если есть зависимости между методами
- classes — все методы одного класса в одном потоке, но разные классы в разных потоках
- tests — все методы одного теста в одном потоке, разные тесты в разных потоках
- time-out — время, после которого тест будет считаться провалившимся, то же что и в аннотации, но распостраняется на все тестовые методы
- junit — JUnit 3 тесты
- annotations — если javadoc, то будет использован доклет для конфигурации
- parameter — параметры, те, что используются в @Parameters
- packages — пакеты, где искать тест-классы
- listeners — слушатели, с их помощью можно расширить функционал TestNG, о них уже сказал пару слов
- method-selectors — селекторы для тестов, должны реализовывать интерфейс IMethodSelector
- suite-files — можно включать другие файлы конфигурации
<test name="Default tests" verbose="1" annotations="JDK" thread-count="10" parallel="classes"> <!-- some staff here --> <groups> <run> <exclude name="integration"/> </run> </groups> <!-- some staff here --> </test>
В данном примере тест будет включать в себя только тесты не относящиеся к группе integration.
Еще тесты могут включать в себя тест-классы, которые в свою очередь могут включать/исключать в себя тест-методы.
<test name="Integration tests"> <groups> <run> <include name="integration"/> </run> </groups> <classes> <class name="com.example.PrioritiesTest"> <methods> <exclude name="third"/> </methods> </class> </classes> </test>
Вывод
Это не все, что можно сказать по этому замечательному фреймворку, все охватить в одной статье очень трудно. Но я постарался раскрыть основные моменты его использования. Тут, конечно, можно было рассказать немного больше про механизм слушателей, про интеграции с другими фреймворками, про конфигурацию тестов, но тогда статья превратилась бы в книгу, да и не знаю я всего. Но, надеюсь, эта статья была кому-то полезна и то, что было сказано, сподвигнет некоторых читателей на использование TestNG, или хотя бы на DDT технику написания тестов.
Примеры можно найти здесь, различные статьи здесь.
