![](https://habrastorage.org/storage/b22b53b6/77c8f76e/35a37c5c/73063f51.jpg)
Наверняка все знакомы с таким понятием как 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 технику написания тестов.
Примеры можно найти здесь, различные статьи здесь.