diff --git a/libraries.gradle b/libraries.gradle index 1d3b9f2cdf..c7138860fd 100644 --- a/libraries.gradle +++ b/libraries.gradle @@ -27,10 +27,17 @@ ext { orgjsonVersion = '20200518' postgresqlEmbeddedVersion = '1.2.8' postgresqlBinVersion = '12.3.0' + //testing + jupiterVersion = '5.5.2' + mockitoVersion = '3.12.4' libraries = [ //jUnit (Tests) - junit: 'org.junit.jupiter:junit-jupiter:5.5.2', + junit: "org.junit.jupiter:junit-jupiter:${jupiterVersion}", + jupiter_api: "org.junit.jupiter:junit-jupiter-api:${jupiterVersion}", + + //mockito (Tests) + mockito: "org.mockito:mockito-core:${mockitoVersion}", //jPOS jpos: "org.jpos:jpos:${jposVersion}", @@ -99,7 +106,6 @@ ext { jetty_servlets: "org.eclipse.jetty:jetty-servlets:${jettyVersion}", jetty_ajp: "org.eclipse.jetty:jetty-ajp:${jettyVersion}", jetty_continuation: "org.eclipse.jetty:jetty-continuation:${jettyVersion}", - jetty_rewrite: "org.eclipse.jetty:jetty-rewrite:${jettyVersion}", // Quartz Scheduler quartz: 'org.quartz-scheduler:quartz:2.3.2', diff --git a/modules/testcore/README.md b/modules/testcore/README.md new file mode 100644 index 0000000000..4a0ddac39b --- /dev/null +++ b/modules/testcore/README.md @@ -0,0 +1,75 @@ +# Module for aiding in unit tests + +This module provides annotations to inject some mock or frequently needed objects during testing. + +For example, it provides an injection for a `Log` object, and a `MUX` mock. + +## Log injection example +In the following example, if the logger does not already exist, a default one, that logs to standard output is created with the given `logger` name, and assinged to the `Log` instance. +```java +@ExtendWith(LogSupplierExtension.class) +class LogTest { + @LogSource(logger = "Q2", realm = "log-test") + Log log; + + @Test + public void testDebug() { + log.debug("debug called"); + } +} +``` + + +## Mux mocking injection example + +This test class is actually executed in this module's test. + +```java +package org.jpos.ee.test; + +import org.jpos.iso.ISOException; +import org.jpos.iso.ISOMsg; +import org.jpos.iso.MUX; +import org.jpos.q2.iso.QMUX; +import org.jpos.util.NameRegistrar; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.when; + +@ExtendWith(MUXSupplierExtension.class) +class MUXSupplierExtensionTest { + + private static final String MUX_NAME = "connected-mux"; + @MUXMock(connected = true, name = MUX_NAME) + MUX connectedMux; + + @MUXMock(connected = false) + MUX disconnectedMux; + + @Test + void testConnectedMux() throws NameRegistrar.NotFoundException { + assertSame(connectedMux, QMUX.getMUX(MUX_NAME)); + assertTrue(connectedMux.isConnected()); + } + + @Test + void testDisconnectedMux() { + assertFalse(disconnectedMux.isConnected()); + } + + @Test + void testMockRequest() throws NameRegistrar.NotFoundException, ISOException { + ISOMsg request = new ISOMsg("2100"); + ISOMsg response = new ISOMsg("2110"); + when(connectedMux.request(same(request), anyLong())).thenReturn(response); + MUX mux = QMUX.getMUX(MUX_NAME); + assertSame(connectedMux, mux); + assertTrue(mux.isConnected()); + assertSame(response, mux.request(request, 1000L)); + } +} +``` diff --git a/modules/testcore/build.gradle b/modules/testcore/build.gradle new file mode 100644 index 0000000000..c31b6631a6 --- /dev/null +++ b/modules/testcore/build.gradle @@ -0,0 +1,7 @@ +description = 'jPOS-EE :: Testing Module' + +dependencies { + implementation libraries.jupiter_api + implementation libraries.mockito + implementation libraries.jpos +} diff --git a/modules/testcore/src/main/java/org/jpos/ee/test/LogSource.java b/modules/testcore/src/main/java/org/jpos/ee/test/LogSource.java new file mode 100644 index 0000000000..e396e40bd5 --- /dev/null +++ b/modules/testcore/src/main/java/org/jpos/ee/test/LogSource.java @@ -0,0 +1,37 @@ +package org.jpos.ee.test; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Injects a {@link org.jpos.util.Log} object in the declared member or parameter. It needs to be used alongside.
+ * {@code @ExtendWith(LogSupplierExtension.class)} + *

+ * Usage example: + *

 {@code
+ *  @ExtendWith(LogSupplierExtension.class)
+ *  class XxxxTest ...{
+ *      ...
+ *      @LogSource
+ *      Log log;
+ *      ....
+ *
+ *      @AfterEach
+ *      tearDown() {
+ *          log.debug(...);
+ *      }
+ *  }
+ * }
+ *

+ */ +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(LogSupplierExtension.class) +public @interface LogSource { + String realm() default ""; + String logger() default ""; +} diff --git a/modules/testcore/src/main/java/org/jpos/ee/test/LogSupplierExtension.java b/modules/testcore/src/main/java/org/jpos/ee/test/LogSupplierExtension.java new file mode 100644 index 0000000000..afbd5a8cf4 --- /dev/null +++ b/modules/testcore/src/main/java/org/jpos/ee/test/LogSupplierExtension.java @@ -0,0 +1,113 @@ +package org.jpos.ee.test; + +import org.jpos.util.Logger; +import org.junit.jupiter.api.extension.*; + +import java.lang.reflect.*; +import java.util.Arrays; +import java.util.function.BiConsumer; + + +public class LogSupplierExtension implements BeforeEachCallback, ParameterResolver, BeforeAllCallback { + + protected static void runOnFields(ExtensionContext context, boolean staticFields, BiConsumer action) { + Arrays.stream(context.getRequiredTestClass().getDeclaredFields()) + .filter(f -> f.isAnnotationPresent(LogSource.class) && Modifier.isStatic(f.getModifiers()) == staticFields) + .forEach(f -> action.accept(f, f.getAnnotation(LogSource.class))); + } + + + protected org.jpos.util.Log getLog(LogSource annotation, Class c) { + Logger logger = annotation.logger().isEmpty() ? TestUtil.getLogger() : Logger.getLogger(annotation.logger()); + String realm = annotation.realm().isEmpty() ? c.getSimpleName() : annotation.realm(); + return new org.jpos.util.Log(logger, realm); + } + + /** + * Called to set up all MUX fields + * @param context The extension context + * @param beforeAll if this is for a beforeAll method set static fields otherwise set instance ones + */ + protected void setUp(ExtensionContext context, boolean beforeAll) { + runOnFields(context, beforeAll, (field, annotation) -> { + try { + if (!field.isAccessible()) field.setAccessible(true); + field.set( context.getTestInstance().orElse(null), getLog(annotation, context.getRequiredTestClass())); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException(e); + } + }); + + } + + + /** + * Callback that is invoked before an individual test and any + * user-defined setup methods for that test have been executed. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void beforeEach(ExtensionContext context) throws IllegalArgumentException{ + setUp(context, false); + } + + /** + * Determine if this resolver supports resolution of an argument for the + * {@link Parameter} in the supplied {@link ParameterContext} for the supplied + * {@link ExtensionContext}. + * + *

The {@link Method} or {@link Constructor} + * in which the parameter is declared can be retrieved via + * {@link ParameterContext#getDeclaringExecutable()}. + * + * @param parameterContext the context for the parameter for which an argument should + * be resolved; never {@code null} + * @param ignored unused + * @return {@code true} if this resolver can resolve an argument for the parameter + * @see #resolveParameter + * @see ParameterContext + */ + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext ignored) throws ParameterResolutionException { + return parameterContext.isAnnotated(LogSource.class) && parameterContext.getTarget().map(Object::getClass).filter(c -> c.isAssignableFrom(LogSource.class)).isPresent(); + } + + /** + * Resolve an argument for the parameter in the supplied {@link ParameterContext} + * for the supplied {@link ExtensionContext}. + * + *

This method is only called by the framework if {@link #supportsParameter} + * previously returned {@code true} for the same {@link ParameterContext} + * and {@link ExtensionContext}. + * + *

The {@link Method} or {@link Constructor} + * in which the parameter is declared can be retrieved via + * {@link ParameterContext#getDeclaringExecutable()}. + * + * @param parameterContext the context for the parameter for which an argument should + * be resolved; never {@code null} + * @param extensionContext the extension context for the {@code Executable} + * about to be invoked; never {@code null} + * @return the resolved argument for the parameter; may only be {@code null} if the + * parameter type is not a primitive + * @see #supportsParameter + * @see ParameterContext + */ + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return getLog(parameterContext.getParameter().getAnnotation(LogSource.class), extensionContext.getRequiredTestClass()); + } + + + /** + * Callback that is invoked once before all tests in the current + * container. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void beforeAll(ExtensionContext context) throws Exception { + setUp(context, true); + } +} diff --git a/modules/testcore/src/main/java/org/jpos/ee/test/MUXMock.java b/modules/testcore/src/main/java/org/jpos/ee/test/MUXMock.java new file mode 100644 index 0000000000..9bad46d3fc --- /dev/null +++ b/modules/testcore/src/main/java/org/jpos/ee/test/MUXMock.java @@ -0,0 +1,54 @@ +package org.jpos.ee.test; + +import org.junit.jupiter.api.extension.ExtendWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + *

Injects a muck mux in the declared member or parameter. It needs to be used alongside
+ * {@code @ExtendWith(MUXSupplierExtension.class)}.

+ *

+ * Usage example: + *

+ *
{@code
+ *  @ExtendWith(MUXSupplierExtension.class)
+ *  class XxxxTest ...{
+ *      ...
+ *      @MUXMock //register the mux mock in name registrar, if not already registered, and injects it
+ *      MUX mux
+ *      ....
+ *
+ *      testXxx() {
+ *          //define return for some condition, this example uses mockito
+ *          when(mux.request(same(request), anyLong())).thenReturn(response);
+ *      }
+ *  }
+ * }
+ */ +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(MUXSupplierExtension.class) +public @interface MUXMock { + String MUX_NAME = ""; + + /** + *

Name by which the mux is going tobe registered in Name Registrar

+ * + *

Don't use the same name for instance and static, unless you don't care the static is unregistered from + * {@link org.jpos.util.NameRegistrar}

+ * + * Defaults to {@code ""}, in which case one with a random name will be generated + * + * @return the name under which the mux mock will be registered. + */ + String name() default MUX_NAME; + + /** + * Tells if the mocked mux should return true when its {@code isConnected()} method is called. + * @return the value the mocked mux {@code isConnected()} method should return. + */ + boolean connected() default true; +} diff --git a/modules/testcore/src/main/java/org/jpos/ee/test/MUXSupplierExtension.java b/modules/testcore/src/main/java/org/jpos/ee/test/MUXSupplierExtension.java new file mode 100644 index 0000000000..a857f69124 --- /dev/null +++ b/modules/testcore/src/main/java/org/jpos/ee/test/MUXSupplierExtension.java @@ -0,0 +1,152 @@ +package org.jpos.ee.test; + +import org.jpos.iso.MUX; +import org.jpos.util.NameRegistrar; +import org.junit.jupiter.api.extension.*; + +import java.lang.reflect.*; +import java.util.Arrays; +import java.util.Random; +import java.util.function.BiConsumer; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MUXSupplierExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver, BeforeAllCallback, AfterAllCallback { + + protected static void runOnFields(ExtensionContext context, boolean staticFields, BiConsumer action) { + Arrays.stream(context.getRequiredTestClass().getDeclaredFields()) + .filter(f -> f.isAnnotationPresent(MUXMock.class) && Modifier.isStatic(f.getModifiers()) == staticFields) + .forEach(f -> action.accept(f, f.getAnnotation(MUXMock.class))); + } + + + protected MUX getMUXMock(MUXMock annotation) { + String name = annotation.name().isEmpty() ? "mux-" + new Random().nextLong() : annotation.name(); + MUX mux = NameRegistrar.getIfExists(name); + if (mux == null) { + mux = mock(MUX.class); + when(mux.isConnected()).thenReturn(annotation.connected()); + NameRegistrar.register("mux." + name, mux); + } + return mux; + } + + /** + * Called to set up all MUX fields + * @param context The extension context + * @param beforeAll if this is for a beforeAll method set static fields otherwise set instance ones + */ + protected void setUp(ExtensionContext context, boolean beforeAll) { + runOnFields(context, beforeAll, (field, annotation) -> { + try { + if (!field.isAccessible()) field.setAccessible(true); + field.set( context.getTestInstance().orElse(null), getMUXMock(annotation)); + } catch (IllegalAccessException e) { + throw new IllegalArgumentException(e); + } + }); + + } + + /** + * Called to tear down all MUX fields + * @param context The extension context + * @param afterAll if this is for an afterAll method unregister only static muxes otherwise only instences. + */ + protected void tearDown(ExtensionContext context, boolean afterAll){ + runOnFields(context, afterAll, (ignored, annotation) -> NameRegistrar.unregister("mux." + annotation.name())); + + } + /** + * Callback that is invoked before an individual test and any + * user-defined setup methods for that test have been executed. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void beforeEach(ExtensionContext context) throws IllegalArgumentException{ + setUp(context, false); + } + + /** + * Callback that is invoked after an individual test and any + * user-defined teardown methods for that test have been executed. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void afterEach(ExtensionContext context) { + tearDown(context, false); + } + + /** + * Determine if this resolver supports resolution of an argument for the + * {@link Parameter} in the supplied {@link ParameterContext} for the supplied + * {@link ExtensionContext}. + * + *

The {@link Method} or {@link Constructor} + * in which the parameter is declared can be retrieved via + * {@link ParameterContext#getDeclaringExecutable()}. + * + * @param parameterContext the context for the parameter for which an argument should + * be resolved; never {@code null} + * @param extensionContext the extension context for the {@code Executable} + * about to be invoked; never {@code null} + * @return {@code true} if this resolver can resolve an argument for the parameter + * @see #resolveParameter + * @see ParameterContext + */ + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return parameterContext.isAnnotated(MUXMock.class); + } + + /** + * Resolve an argument for the parameter in the supplied {@link ParameterContext} + * for the supplied {@link ExtensionContext}. + * + *

This method is only called by the framework if {@link #supportsParameter} + * previously returned {@code true} for the same {@link ParameterContext} + * and {@link ExtensionContext}. + * + *

The {@link Method} or {@link Constructor} + * in which the parameter is declared can be retrieved via + * {@link ParameterContext#getDeclaringExecutable()}. + * + * @param parameterContext the context for the parameter for which an argument should + * be resolved; never {@code null} + * @param extensionContext the extension context for the {@code Executable} + * about to be invoked; never {@code null} + * @return the resolved argument for the parameter; may only be {@code null} if the + * parameter type is not a primitive + * @see #supportsParameter + * @see ParameterContext + */ + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return getMUXMock(parameterContext.getParameter().getAnnotation(MUXMock.class)); + } + + /** + * Callback that is invoked once after all tests in the current + * container. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void afterAll(ExtensionContext context) throws Exception { + tearDown(context, true); + } + + /** + * Callback that is invoked once before all tests in the current + * container. + * + * @param context the current extension context; never {@code null} + */ + @Override + public void beforeAll(ExtensionContext context) throws Exception { + setUp(context, true); + } +} diff --git a/modules/testcore/src/main/java/org/jpos/ee/test/TestUtil.java b/modules/testcore/src/main/java/org/jpos/ee/test/TestUtil.java new file mode 100644 index 0000000000..f121c19677 --- /dev/null +++ b/modules/testcore/src/main/java/org/jpos/ee/test/TestUtil.java @@ -0,0 +1,18 @@ +package org.jpos.ee.test; + +import org.jpos.util.LogListener; +import org.jpos.util.Logger; +import org.jpos.util.SimpleLogListener; + +public class TestUtil { + private final static Logger logger = new Logger(); + + static { + LogListener stdout = new SimpleLogListener(); + logger.addListener(stdout); + } + + public static Logger getLogger() { + return logger; + } +} diff --git a/modules/testcore/src/test/java/org/jpos/ee/test/LogSupplierExtensionTest.java b/modules/testcore/src/test/java/org/jpos/ee/test/LogSupplierExtensionTest.java new file mode 100644 index 0000000000..b0a8b29454 --- /dev/null +++ b/modules/testcore/src/test/java/org/jpos/ee/test/LogSupplierExtensionTest.java @@ -0,0 +1,51 @@ +package org.jpos.ee.test; + +import org.jpos.util.Log; +import org.jpos.util.Logger; +import org.jpos.util.SimpleLogListener; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(LogSupplierExtension.class) +class LogSupplierExtensionTest { + static final String LOGGER_NAME = "test-logger"; + @LogSource (logger=LOGGER_NAME, realm="log-test") + Log log; + + Logger logger; + + SimpleLogListener logListener = new SimpleLogListener(); + + @BeforeEach + void setUp() { + logger = Logger.getLogger(LOGGER_NAME); + logListener = spy(new SimpleLogListener()); + logger.addListener(logListener); + } + + @AfterEach + void tearDown() { + logger.removeListener(logListener); + } + + @Test + public void testDebug(){ + log.debug("debug called"); + verify(logListener, times(1)).log(argThat(event -> { + assertSame(log, event.getSource()); + assertEquals(Log.DEBUG, event.getTag()); + assertEquals(1, event.getPayLoad().size()); + assertEquals("debug called", event.getPayLoad().get(0)); + return true; + })); + } + +} \ No newline at end of file diff --git a/modules/testcore/src/test/java/org/jpos/ee/test/MUXSupplierExtensionTest.java b/modules/testcore/src/test/java/org/jpos/ee/test/MUXSupplierExtensionTest.java new file mode 100644 index 0000000000..41ca3ecc0b --- /dev/null +++ b/modules/testcore/src/test/java/org/jpos/ee/test/MUXSupplierExtensionTest.java @@ -0,0 +1,46 @@ +package org.jpos.ee.test; + +import org.jpos.iso.ISOException; +import org.jpos.iso.ISOMsg; +import org.jpos.iso.MUX; +import org.jpos.q2.iso.QMUX; +import org.jpos.util.NameRegistrar; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.when; + +@ExtendWith(MUXSupplierExtension.class) +class MUXSupplierExtensionTest { + + private static final String MUX_NAME = "connected-mux"; + @MUXMock(connected = true, name = MUX_NAME) + MUX connectedMux; + + @MUXMock(connected = false) + MUX disconnectedMux; + @Test + void testConnectedMux() throws NameRegistrar.NotFoundException { + assertSame(connectedMux, QMUX.getMUX(MUX_NAME)); + assertTrue(connectedMux.isConnected()); + } + + @Test + void testDisconnectedMux() { + assertFalse(disconnectedMux.isConnected()); + } + + @Test + void testMockRequest() throws NameRegistrar.NotFoundException, ISOException { + ISOMsg request = new ISOMsg("2100"); + ISOMsg response = new ISOMsg("2110"); + when(connectedMux.request(same(request), anyLong())).thenReturn(response); + MUX mux = QMUX.getMUX(MUX_NAME); + assertSame(connectedMux, mux); + assertTrue(mux.isConnected()); + assertSame(response, mux.request(request, 1000L)); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index cfa0ad84a1..2a70dbda42 100644 --- a/settings.gradle +++ b/settings.gradle @@ -46,7 +46,8 @@ include ':modules:core', ':modules:seqno', ':modules:db-flyway', ':modules:elasticsearch', - ':modules:bom' + ':modules:bom', + ':modules:testcore' rootProject.name = 'jposee'