Интеграционные тесты на JUnit-е в Spring-овом приложении пишут, конечно же, почти все. Удобно: описал в коде тестов бизнес-кейсы, указал, как проинъектить зависимости, поставил по вкусу defaultRollback=true (чтобы не приходилось мучиться с порчей данных в базе) - и смотри, работает или не работает твое приложение по всей или части цепочки вызова, прямо в IDE или CI. Экономится масса времени на ручном тестировании, окупается при рефакторингах, когда бизнес-кейсы и интерфейсы остаются неизменными. Лепота.
И вот мне захотелось добавить заодно проверку работоспособности бизнес-кейсов, критичных к одновременному исполнению одного и того же сценария пользователем: например, не портят ли параллельные транзакции, запущенные внешним планировщиком, друг другу данные. Тесты соответствующих цепочек вызова в одной транзакции уже были, захотелось простым движением руки сделать так, чтобы запускалось много транзакций, бегущих в параллель.
Увы, out of the box ничего не нашлось: нет (пока?) в Spring возможности, добавив, скажем, аннотацию на метод, прогнать его в нескольких потоках, каждый в своей транзакции. Переписывать каждый тест так, чтобы управление потоками и транзакциями происходило внутри тела теста, не хотелось - тестов таких по приложению нужно было прогнать не один десяток. JMeter не годился, т.к. требовал деплоя и не интегрировался с maven-ом, а хотелось гонять такие тесты прямо в процессе разработки.
Первое, что пришло в голову, написать некий метод в родительском для всех моих тестов классе, который бы запускал тесты в многопоточном режиме (далее везде "многопоточный/мультранзакционный" подразумевает параллельный запуск нескольких потоков, когда в каждом потоке стартуется своя Spring-транзакция). От этого подхода я сразу отказался: поскольку @Transactional у меня указана непосредственно на родительском классе всех моих тестов, то ручное управление транзакциями внутри теста весьма чревато боком, если вообще возможно. Пришлось бы переставлять эту аннотацию на конкретные классы, а местами - и только на конкретные методы в большом количестве мест - к черту. Хочу пометить нужные тест-кейсы аннотацией, указать в ней количество потоков - и все! Выглядеть она будет, например, так:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConcurrentRun {
int threads() default 10;
}
Хорошо, что нам на этот счет предлагает Spring и JUnit? Запуск тестов JUnit-ом осуществляется при помощи BlockJUnit4ClassRunner, от которого наследуется Spring-овый SpringJUnit4ClassRunner, отвечающий, как раз, за запуск всех тестов с использованием IoCC. Лезем в код. Ага, вот это место, в котором запускается проверка теста:
@Override
protected void runChild(FrameworkMethod frameworkMethod, RunNotifier notifier) {
EachTestNotifier eachNotifier = springMakeNotifier(frameworkMethod, notifier);
if (isTestMethodIgnored(frameworkMethod)) {
eachNotifier.fireTestIgnored();
return;
}
eachNotifier.fireTestStarted();
try {
methodBlock(frameworkMethod).evaluate();
} catch (AssumptionViolatedException e) {
eachNotifier.addFailedAssumption(e);
} catch (Throwable e) {
eachNotifier.addFailure(e);
} finally {
eachNotifier.fireTestFinished();
}
}
Где methodBlock(...) - это метод, который осуществляет создание инстанса теста, и вызывает, собственно, метод теста, обернутый во все эти JUnit-овые @Before/@After и так далее. Вот таким образом:
@Override
protected Statement methodBlock(FrameworkMethod frameworkMethod) {
Object testInstance;
try {
testInstance = new ReflectiveCallable() {
@Override
protected Object runReflectiveCall() throws Throwable {
return createTest(); //#!!!
}
}.run();
} catch (Throwable e) {
return new Fail(e);
}
Statement statement = methodInvoker(frameworkMethod, testInstance);
statement = possiblyExpectingExceptions(frameworkMethod, testInstance, statement);
statement = withRulesReflectively(frameworkMethod, testInstance, statement);
statement = withBefores(frameworkMethod, testInstance, statement);
statement = withAfters(frameworkMethod, testInstance, statement);
statement = withPotentialRepeat(frameworkMethod, testInstance, statement);
statement = withPotentialTimeout(frameworkMethod, testInstance, statement);
return statement;
}
(тут прямо-таки просится fp-style вызовов всех этих методов, но да не о том речь)
Ну что ж, нет проблем - перепишем этот кусок кода так, чтобы запуск тестов происходил в нескольких потоках:
@Override
protected void runChild(FrameworkMethod frameworkMethod, RunNotifier notifier) {
EachTestNotifier eachNotifier = innerMakeNotifier(frameworkMethod, notifier);
if (isTestMethodIgnored(frameworkMethod)) {
eachNotifier.fireTestIgnored();
return;
}
int threads = 1;
if (frameworkMethod != null){
Method method = frameworkMethod.getMethod();
if (method.getAnnotation(ConcurrentRun.class) != null){
int threadsFromAnnotation = method.getAnnotation(ConcurrentRun.class).threads();
if (threadsFromAnnotation > 1){
threads = threadsFromAnnotation;
}
}
}
if (threads > 1) {
evaluateConcurrently(frameworkMethod, eachNotifier, threads);
} else {
evaluateSameThread(frameworkMethod, eachNotifier);
}
}
protected void evaluateSameThread(FrameworkMethod frameworkMethod, EachTestNotifier eachNotifier) {
try {
methodBlock(frameworkMethod).evaluate();
}
catch (AssumptionViolatedException e) {
eachNotifier.addFailedAssumption(e);
}
catch (Throwable e) {
eachNotifier.addFailure(e);
}
finally {
eachNotifier.fireTestFinished();
}
}
protected void evaluateConcurrently(FrameworkMethod frameworkMethod, EachTestNotifier eachNotifier, int threads) {
try {
CountDownLatch endLatch = new CountDownLatch(threads);
for (int i = 0; i < threads; i++){
TestThreadEvaluationTask evaluationTask =
new TestThreadEvaluationTask(eachNotifier, frameworkMethod, endLatch, i);
evaluationTask.start();
}
endLatch.await();
eachNotifier.fireTestFinished(); // #1
} catch (InterruptedException ie){
Thread.currentThread().interrupt();
}
}
private class TestThreadEvaluationTask extends Thread {
final EachTestNotifier notifier;
final FrameworkMethod fwMethod;
final CountDownLatch endLatch;
TestThreadEvaluationTask(EachTestNotifier notifier,
FrameworkMethod fwMethod,
CountDownLatch endLatch,
int number) {
this.notifier = notifier;
this.fwMethod = fwMethod;
this.endLatch = endLatch;
setName("Test "+fwMethod.getName()+" thread "+number);
}
@Override
public void run() {
try {
methodBlock(fwMethod).evaluate();
}
catch (AssumptionViolatedException e) {
notifier.addFailedAssumption(e); //#2
}
catch (Throwable e) {
notifier.addFailure(e); //#3
}
finally {
endLatch.countDown();
}
}
}
Что мы в этом коде делаем? Смотрим на наличие нашей аннотации - если нет, запускаем тест, как раньше в том же потоке. Если есть - запускаем несколько потоков по оценке теста, ждем, пока они все так или иначе отработают. Обратите внимание на строчки помеченные ##1-3: событие завершения теста будет одно, независимо от количества потоков, что даст нам правильное поведение, скажем, в CI - у нас не случится внезапного вырастания количества тестов в отчете во много раз только от того, что мы поставили аннотацию. В то же время, случись что, ошибок нам он напишет столько, в скольких потоках shit happened.
Все? Нет, черта с два. Проблема в том, что контекст менеджер TestContextManager, в рамках которого запускается тест, и который отвечает за значения инъецированных свойств, один на инстанс runner-а. Если взглянуть на строчку метода methodBlock, помеченную "!!!", становится ясно к чему это приведет: при запуске потоки начнут перезаписывать друг другу контекстные данные, приводя к некорректно инициализированным зависимостям или и вовсе NPE. Простое решение - перевести контекст менеджер в ThreadLocal.
// bla-bla-bla...
public class ConcurrentTestExecutionRunner extends BlockJUnit4ClassRunner {
// bla-bla-bla...
public ConcurrentTestExecutionRunner(Class clazz) throws InitializationError {
super(clazz);
}
private ThreadLocal<testcontextmanager> tlTestContextManager = new ThreadLocal<testcontextmanager>(){
@Override
protected TestContextManager initialValue() {
Class clazz = getTestClass().getJavaClass();
return new TestContextManager(clazz, getDefaultContextLoaderClassName(clazz));
}
};
//... others runner's methods
}
Здесь мне пришлось полностью отказаться от наследования SpringJUnit4ClassRunner и унаследоваться от BlockJUnit4ClassRunner, просто скопировав некоторые методы Spring-ового в свой runner, потому что в SpringJUnit4ClassRunner контекст менеджер объявлен как private final.
После этих изменений тесты успешно заработали в многопоточном варианте. Для удобства, я определил базовый класс для всех таких тестов, добавив туда статические методы для вызова разнообразных очищающих базу sql-скриптов в @AfterClass, подобные тем, которые есть в AbstractTransactionalJUnit4SpringContextTests, только там они нестатические и для @AfterClass непригодны.
В конечном итоге родительский класс для всех тестов выглядит примерно так:
@RunWith(ConcurrentTestExecutionRunner.class)
@Transactional
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class,
DirtiesContextTestExecutionListener.class,
TransactionalTestExecutionListener.class})
public abstract class AbstractConcurrentTransactionalSpringTest implements ApplicationContextAware {
//blah-blah-blah...
}
Ну и вызов метода теста, соответственно,
@Test
@ConcurrentRun(threads = 100)
public void testSomeBusinessCaseInHundredOfTransactions() {
// here go calls, asserts etc.
}
Надеюсь, кому-то это окажется полезным.
Теги:
spring
junit