JUnit тесты для логирования

в 18:58, , рубрики: java, junit, log4j, mockito, PowerMock, slf4j, тестирование

Логирование относится к сквозной функциональности — разбросанной по коду и, как правило, редко покрываемой юнит-тестами. Слабое покрытие тестами, очевидно, связано с тем что вывод в лог не всегда достаточно важен и воспринимается скорее как вспомогательная функция а не цель работы метода, к тому же тестировать сквозную функциональность бывает достаточно сложно.
Но когда корректность вывода в лог становится критичной или же чувство прекрасного требует от нас полного покрытия кода тестами — без тестирования логгеров становится не обойтись.

Предположим у нас есть простой класс с log4j-логгером и методом doSomethingWithInt

import org.apache.log4j.Logger;

public class ClassWithLog4JLogger {

 private static final Logger logger =  Logger.getLogger(ClassWithLog4JLogger.class);

   public void doSomethingWithInt(int i){

       logger.info(" - метод doSomethingWithInt вызван с параметром i = " +i);
       if (i>0){
           logger.info(" - параметр i больше нуля");
       }else{
           logger.info(" - параметр i больше или равен нулю");
       }
   }
}

Мы хотим протестировать факт того что вызов метода

new ClassWithLog4JLogger().doSomethingWithInt(1);

приведет к выводу в лог

— метод doSomethingWithInt вызван с параметром i = 1
— параметр i больше нуля

Традиционный подход к тестированию предполагает, инжектирование mock-объекта (с помощью Mockitio) в тестируемый класс и, после отработки тестируемого кода, проверку того как и какие параметры передавались в mock.

Проблема в том, что инжектировать логгер в наш класс достаточно сложно — он не передается в ClassWithLog4JLogger а возвращается из static-метода, подменять returnValue которого Mockito не умеет (и у этого есть определенные основания — Mockito предназначено для тестирования объектов, в то время как static-метод относится к классу а не объекту). Но проблема, конечно, решаема — причем несколькими способами

Способ 1. Mock для log4j-Appender-а

“За неимением горничной имеем дворника...” Пусть мы не можем подменить сам логгер — но мы можем подсунуть ему mock-аппендер и убедиться, что логгер передает в аппеддер те события которые мы ожидаем.

Добавим проект JUnit и Mockito

<dependency>
   <groupId>junit</groupId>
   <artifactId>junit</artifactId>
   <version>4.12</version>
   <scope>test</scope>
</dependency>

<dependency>
   <groupId>org.mockito</groupId>
   <artifactId>mockito-all</artifactId>
   <version>1.10.19</version>
   <scope>test</scope>
</dependency>

и напишем вот такой тест

import org.apache.log4j.Appender;
import org.apache.log4j.Logger;
import org.apache.log4j.spi.LoggingEvent;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@RunWith(MockitoJUnitRunner.class) // тест управляется Mockito который создаст и инициализирует мок-объекты
public class ClassWithLog4JLoggerAppenderMockTest {

   @Mock
   Appender mockAppender;

   @Test
   public void doLoggingTest() {

       // Получаем логгер для нашего класса
       Logger logger = Logger.getLogger(ClassWithLog4JLogger.class);
       // Передаем логгеру mock-аппендер
       logger.addAppender(mockAppender);

       // вызываем тестируемый метод
       ClassWithLog4JLogger classWithLog4JLogger = new ClassWithLog4JLogger();
       classWithLog4JLogger.doSomethingWithInt(1);

       // 'перехватчик' передаваемых в mock параметров
       ArgumentCaptor<LoggingEvent> eventArgumentCaptor = ArgumentCaptor.forClass(LoggingEvent.class);

       //проверяем, что аппендер вызывался два раза
       verify(mockAppender, times(2)).doAppend(eventArgumentCaptor.capture());
       //проверяем с какими параметрами вызывался аппендер
       Assert.assertEquals(" - метод doSomethingWithInt вызван с параметром i = 1", eventArgumentCaptor.getAllValues().get(0).getMessage());
       Assert.assertEquals(" - параметр i больше нуля", eventArgumentCaptor.getAllValues().get(1).getMessage());

//проверяем с какими параметрами вызывался аппендер (уровень логирования)
Assert.assertEquals(Level.INFO, eventArgumentCaptor.getAllValues().get(0).getLevel());
Assert.assertEquals(Level.INFO, eventArgumentCaptor.getAllValues().get(1).getLevel());

   }
}

Все вроде бы достаточно просто и не нуждается в пояснении.

Единственный минус такого подхода — так как мы тестируем не логгер а аппендер то мы проверяем не аргументы методов логгера а аргументы которые логгер передает аппендеру (LoggingEvent-ы), а их верификация занимает чуть больше строчек кода.

Данный подход сработает и в том случае если в качестве логгера используется slf4J Это надстройка над log4j (и еще несколькими фреймворками логирования), позволяющая, например, выводить в лог параметры без конкатинации строк (см. пример ниже). Сам slf4J-логгер не имеет методов для добавления аппендера. Но при этом он в процессе работы использует нижестоящий фреймворк (из найденных в classpath). Если этим фреймворком будет log4j то мы можем подсунуть mock-аппендер в log4j-логгер — он в свою очередь будет вызван из slf4J

Итак, добавляем зависимости на slf4j и его связку с log4j

<dependency>
   <groupId>org.slf4j</groupId>
   <artifactId>slf4j-api</artifactId>
   <version>1.7.25</version>
</dependency>

<dependency>
   <groupId>org.slf4j</groupId>
   <artifactId>slf4j-log4j12</artifactId>
   <version>1.7.25</version>
</dependency>

И протестируем класс почти такой же как в предыдущем примере — отличие только в логгере и передачи параметров в лог (теперь без конкатенации строк)

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ClassWithSlf4JLogger {

   private static final Logger logger = LoggerFactory.getLogger(ClassWithSlf4JLogger.class);

   public void doSomethingWithInt(int i) {

       logger.info(" - метод doSomethingWithInt вызван с параметром i = {}", i);
       if (i > 0) {
           logger.info(" - параметр i больше нуля");
       } else {
           logger.info(" - параметр i больше или равен нулю");
       }
   }
}

Тест для него останется точно таким же — изменится только название класса для которого получаем логгер (при том что это по-прежнему log4j а не slf4j логгер)!

Logger logger = Logger.getLogger(ClassWithSlf4JLogger.class);

Способ 2. Подмена slf4j-имплементации.
Но что если мы все же хотим подменить не аппендер а сам логгер? Это таки-возможно. Как уже говорилось выше — slf4 использует на выбор один из нижестоящих фреймворков (log4j, logback и т.д.) Можно добавить в проект еще одну реализацию и при этом удалить из classpath остальные — тогда slf4 “подхватит” именно ее. А в тестовой реализации логгера есть методы позволяющие проверить его вызовы.

Итак — добавляем зависимость

<dependency>
   <groupId>uk.org.lidalia</groupId>
   <artifactId>slf4j-test</artifactId>
   <version>1.2.0</version>
   <scope>test</scope>
</dependency>

и — ВАЖНО(!) удаляем в процессе сборки другие slf4j-логгеры если они есть в проекте.

<build>
   <plugins>
       <plugin>
           <artifactId>maven-surefire-plugin</artifactId>
           <configuration>
               <classpathDependencyExcludes>
                   <classpathDependencyExcludes>org.slf4j:slf4j-log4j12</classpathDependencyExcludes>
               </classpathDependencyExcludes>
           </configuration>
       </plugin>
   </plugins>
</build>

Тест (для класса, используемого в прошлом примере) выглядит крайне просто

import org.junit.After;
import org.junit.Assert;
import org.junit.Test;
import uk.org.lidalia.slf4jtest.TestLogger;
import uk.org.lidalia.slf4jtest.TestLoggerFactory;

public class ClassWithSlf4JTestLoggerTest {

   //получаем тестовый логгер
   TestLogger logger = TestLoggerFactory.getTestLogger(ClassWithSlf4JLogger.class);

   @Test
   public void doLoggingTest() {

       ClassWithSlf4JLogger classWithSlf4JLogger = new ClassWithSlf4JLogger();
       classWithSlf4JLogger.doSomethingWithInt(1);

       //проверяем сообщения передаваемые в логгер
       Assert.assertEquals(" - метод doSomethingWithInt вызван с параметром i = {}", logger.getLoggingEvents().asList().get(0).getMessage());
       Assert.assertEquals(1, logger.getLoggingEvents().asList().get(0).getArguments().get(0));
       Assert.assertEquals(" - параметр i больше нуля", logger.getLoggingEvents().asList().get(1).getMessage());
       Assert.assertEquals(2,  logger.getLoggingEvents().asList().size());
   }

   @After
   public void clearLoggers() {
       TestLoggerFactory.clear();
   }
}

Проще некуда. Но есть и минус — работать тест будет только в связке с maven-ом или другой системой сборки которая удалит классы других логгеров, при этом предыдущий тест, заточенный на slf4j-log4j не отработает. На мой взгляд это не очень удобно так как сковывает нас в используемых средствах (обязательно запуск мавеном) и инструментах (не использование в тестах других логгеров).

Способ 3. Mock-логгер при помощи PowerMock

PowerMock — это как Mockito. Но круче. Чем круче? Тем, что может работать со static-методами, финальными классами, protected и даже private полями… Эдакий молоток в ювелирной лавке (кстати, именно кувалда изображена на эмблеме PowerMock) — в повседневном использовании инструмент слишком мощный, но иногда и без него — никуда. Вот и для тестирования логирования он отлично подходит — мы просто переопределялем метод LoggerFactory.getLogger и подсовываем ему наш mock-объект,

<dependency>
   <groupId>org.powermock</groupId>
   <artifactId>powermock-api-mockito</artifactId>
   <version>1.7.3</version>
   <scope>test</scope>
</dependency>

<dependency>
   <groupId>org.powermock</groupId>
   <artifactId>powermock-module-junit4</artifactId>
   <version>1.7.3</version>
   <scope>test</scope>
</dependency>

import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.powermock.api.mockito.PowerMockito;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.mockito.Matchers.any;
import static org.mockito.Matchers.anyString;
import static org.mockito.Matchers.anyVararg;
import static org.powermock.api.mockito.PowerMockito.mock;

@RunWith(PowerMockRunner.class)
// информируем PowerMock что придется вносить изменения в класс LoggerFactory
@PrepareForTest({LoggerFactory.class})
public class PowerMockitoLoggingTest {

   // наш фейковый логгер
   private static Logger logger = mock(Logger.class);;

   // перееопределяем работу метода LoggerFactory.getLogger - теперь при вызове всегда вернет наш логгер
   static{
       PowerMockito.spy(LoggerFactory.class);
       try {
           PowerMockito.doReturn(logger).when(LoggerFactory.class, "getLogger",
                   any());
       }catch (Exception e){
           e.printStackTrace();
       }
   }

   @Test
   public void doLoggingTest() {

       ClassWithSlf4JLogger classWithSlf4JLogger = new ClassWithSlf4JLogger();
       classWithSlf4JLogger.doSomethingWithInt(1);

       //протестируем вызовы нашего логгера.
       // ради разнообразия - убедимся заодно в том, что методы логгера вызывались не просто с верными параметрами но и в нужном порядке
       InOrder inOrd = Mockito.inOrder(logger);
       inOrd.verify(logger).info(" - метод doSomethingWithInt вызван с параметром i = {}",1);
       inOrd.verify(logger).info(" - параметр i больше нуля");
       Mockito.verify(logger, Mockito.times(1)).info(anyString());
       Mockito.verify(logger, Mockito.times(1)).info(anyString(), anyVararg());
   }

Резюме

Все способы имеют право на существование. Мокирование аппендера представляется наиболее простым, не требующем использования новых (кроме JUnit и Mockito) библиотек, но не работает непосредственно с логгером.

Slf4j-test требует минимум кода — но заставляет играться с подменой классов в classpath. А PowerMock достаточно прост и позволяет инжектить в тестируемый класс mock-логгер.

Код примеров

Автор: al_stal

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js