netmikey/logunit

False assertions when running tests in parallel

MosheElisha opened this issue · 2 comments

Hi,

First, I would like to thank you for this project. It is nicely implemented and very useful.

I would like to report an issue when running the tests in parallel with the following properties:

junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent

Attaching a small project to reproduce - parallel-tests.zip

If I uncomment the @Execution(ExecutionMode.SAME_THREAD) on the ParallelTest class or use @ResourceLock("LogCapturer") on both test methods, everything works.

Run the tests by executing:

mvn clean test

BTW - I used Flogger which uses slf4j to log4j2

Thanks!

After digging into this a bit, I'm afraid parallel execution won't work for logunit because of the very nature of how logging frameworks work.

Let me explain: logunit works by injecting its own appender, configured with all loggers specified in the LogCapturer.create() call, into the logging framework's configuration before a test method. Those loggers and the appender are removed from the logging framework after that test method, re-added for the next one and so on. This ensures a clean context for each test, even though the logging framework, by nature, is global. Indeed, since logging frameworks are not instantiated per-thread, or even per-test-method but globally for the VM, running logunit in parallel will constantly overwrite the logging framework's configuration, destroying logunit's own context.

The first solution that comes to mind would be redesigning Logunit so that the injection of its appenders and loggers would be handled globally, using some mapping between logger/appender and the test method who asked for them. However, even if we implemented this, you still couldn't tell what test method a specific log message came from. It's like a printer that would be getting print jobs from several computers at the same time: even if it manages to print each page correctly, you couldn't tell what computer a specific page came from just by looking at the page.

Here's an example: Let's say you have 2 test methods, each in its own test class and a unit under test like this:

public class Tested {
  private Logger log = LoggerFactory.getLogger(Tested.class);
  public void doSomething(int arg) {
    if(arg > 0) {
      log.info("positive");
    }
  }
}
public class ParallelTest1 {
  @RegisterExtension
  LogCapturer logCapturer = LogCapturer.create().captureForType(Tested.class);
  public void testZero() {
    new Tested().doSomething(0);
    logCapturer.assertDoesNotContain("positive");
  }
}
public class ParallelTest2 {
  @RegisterExtension
  LogCapturer logCapturer = LogCapturer.create().captureForType(Tested.class);
  public void testPositive() {
    new Tested().doSomething(1);
    logCapturer.assertContains("positive");
  }
}

Now, if both of your tests ParallelTest1 and ParallelTest2 are started concurrently by JUnit, your log file will contain:

10:08:57.563 [ForkJoinPool-1-worker-2] INFO  com.example.Tested - positive

Logunit would have no way of knowing what test method emitted this log entry. So depending on timing (did someone say race condition?), testZero() would either fail or succeed because testZero()'s logCapturer could contain the "positive" log event from testPositive().


I guess my advice would be: if logging is so important that you have to cover it in your tests, and if you absolutely need to run them in parallel, then a solution might be to encapsulate the critical log messages into an interface with an implementation delegating to your log framework at runtime, mocking that interface in test and run your expectations on those mocks.

Something like this setup:

public interface CriticalLog {
  public void accountDeleted(int id);
}

public class CriticalLogImpl {
  private Logger log = LoggerFactory.getLogger(CriticalLogImpl.class);
  public void accountDeleted(int id) {
    log.warn("Account " + id + " deleted.");
  }
}

public class AccountController {
  private CriticalLog log; // use e.g. injection
  public void deleteAccount(int id) {
    // ... do something ...
    log.accountDeleted(id);
  }
}

public class AccountControllerTest {
  public void testAccountDeletion(@Mock CriticalLog logMock) {
    AccountController tested = new AccountController();
    tested.setCritialLog(logMock);
    tested.deleteAccount(3);
    Mockito.verify(logMock).accountDeleted(3);
  }
}

Hope this helps!

Thank you, @netmikey for digging into this issue! I assumed it won't be easy / possible.

There are very few tests in which I need to test the logger. I will go with the approach of using a @ResourceLock. That is completely acceptable for me.

Thank again!