Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Swap/Change SystemContext objects with mocks #20

Closed
yaskor opened this issue May 21, 2019 · 19 comments
Closed

Swap/Change SystemContext objects with mocks #20

yaskor opened this issue May 21, 2019 · 19 comments
Assignees
Milestone

Comments

@yaskor
Copy link

yaskor commented May 21, 2019

It would be awesome if dinject could swap objects for a test and reverert to the real object afterwards.

In my case Im trying to write Selenium usecase tests. The javalin server is allready started with all its controllers (dinject controlled singletons), but at some point I want that some controllers behave different.

@ExtendWith({SeleniumExtension.class})
public class InternalRestTest {

    private final Long expectedChecksum = 101010101L;

    @BeforeEach
    public void beforeEach(SeleniumHelper sh) throws Exception {
        sh.navigateTo("/");
    }

    @Test
    public void test_getBundleChecksum(SeleniumHelper sh) {

        InternalController internalRest = Mockito.spy(InternalController.class);
        doReturn(expectedChecksum).when(internalRest).getBundleChecksum();

        try (Context context = SystemContext.swapBean(internalRest)) { //<-- this would be nice
            //Not working with context here but Im expecting a different behavior from allready
            //started server

            Long response = sh.await("internalRest.getBundleChecksum()", Long.class);
            assertThat(response).isEqualTo(expectedChecksum);
        }
    }
}

This is the JUnit5 extension witch starts the server:

public class SeleniumExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, ParameterResolver {

    private WebDriver driver;
    private final int port = 80;

    @Override
    public void beforeAll(ExtensionContext ec) throws Exception {
        Application.main(null);
        WebDriverManager.chromedriver().setup();
    }

    @Override
    public void beforeEach(ExtensionContext ec) throws Exception {
        System.setProperty("webdriver.chrome.silentOutput", "true");
        ChromeOptions options = new ChromeOptions();
        if (equalsIgnoreCase(System.getProperty("selenium.headless"), "true")) {
            options.addArguments("headless");
        }
        driver = new ChromeDriver(options);
        driver.manage().window().setSize(new Dimension(1_440, 1_050));
        driver.manage().timeouts().implicitlyWait(10, SECONDS);
        driver.manage().timeouts().setScriptTimeout(10, SECONDS);
    }

    @Override
    public void afterEach(ExtensionContext ec) throws Exception {
        driver.quit();
    }

    @Override
    public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) throws ParameterResolutionException {
        return pc.getParameter().getType() == SeleniumHelper.class;
    }

    @Override
    public Object resolveParameter(ParameterContext pc, ExtensionContext ec) throws ParameterResolutionException {
        return new SeleniumHelper(driver, port);
    }
}

And this is the main method called in the extension

public class Application {

    public static void main(String[] args) {

        InternalController internalController = getBean(InternalController .class);
        Security securityController = getBean(Security.class);

        Javalin javalin = Javalin.create()
                .disableStartupBanner()
                .enableCaseSensitiveUrls()
                .enableStaticFiles("/static")
                .accessManager(securityController)
                .start(80);

        javalin.post("/login", securityController::login, ANONYMOUS.asSet());
        javalin.get("/logout", securityController::logout, AUTHENTICATED.asSet());

        javalin.register(internalController);
    }
}
@rbygrave
Copy link
Contributor

Yes, that does sounds like a very useful idea. It might be hard to implement given the controller instance is already wired?

I hope to be able to look at it tomorrow night.

@rbygrave
Copy link
Contributor

Note: The existing designed approach for doing something like this is:

@Test
public void myComponentTest() {

  // we have some test doubles we want to use
  MyRedisApi mockRedis = mock(MyRedisApi.class);
  MyDatabaseApi mockDb = mock(MyDatabaseApi.class);

  // create the context programmatically with some
  // test doubles rather than the real dependencies
  try (BeanContext context = new BootContext()
    .withBeans(mockRedis, mockDb)
    .load()) {

    // perform a component test (using the test doubles)
    CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
    coffeeMaker.brew();
  }
}

The implication for the above selenium integration test would be ... we'd wire up a whole Javalin instance that used the mocked controllers. That is, the scope of the mock controllers would be for that Javalin instance ... rather than for a closure.

@yaskor
Copy link
Author

yaskor commented May 23, 2019

Hi @rbygrave

Thx, I wasn't aware of this.

While trying to implement I encountered a Problem.
The mocked Bean is always null when I try to access it...

Mockito version is 1.10.19 (latest stable)

        HtmlController hc = Mockito.mock(HtmlController.class);
        System.out.println(hc); // <--- not null

        try (BeanContext context = new BootContext().withBeans(hc).load()) {
            HtmlController htmlController = context.getBean(HtmlController.class);
            Security securityController = context.getBean(Security.class);

            System.out.println(htmlController); //<--- prints null
            System.out.println(securityController);
        }

@rbygrave
Copy link
Contributor

Ok, my bad. My use of this has been with test doubles that match the class. That is:

HtmlController hc = Mockito.mock(HtmlController.class);

// false because mockito generates an interesting class here
boolean match = HtmlController.class.equals(hc.getClass()); 

That is ... instead of .withBeans(hc) we instead need to additionally supply the Class that we want as the target for dependency injection - HtmlController.class.

HtmlController hc = Mockito.mock(HtmlController.class);

new BootContext()
.withBean(HtmlController.class, hc)   // the class for injection + the mock instance
.load()

This withBean(Class, mockInstance) method does not exist ... I'll need to add it.

@rbygrave rbygrave self-assigned this May 23, 2019
@rbygrave rbygrave added this to the 1.5 milestone May 23, 2019
@rbygrave
Copy link
Contributor

So fixing this in version 1.5 by adding support for withBean(Pump.class, mock) ...

  @Test
  public void withMockitoMock_expect_mockUsed() {

    Pump mock = Mockito.mock(Pump.class);

    try (BeanContext context = new BootContext()
      .withBean(Pump.class, mock)
      .load()) {

      Pump pump = context.getBean(Pump.class);
      assertThat(pump).isSameAs(mock);

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
    }
  }

@rbygrave
Copy link
Contributor

Just released 1.5 to maven central ... so you should be able to try that and confirm that works as you'd hope by using:

HtmlController hc = Mockito.mock(HtmlController.class);

...
new BootContext()
.withBean(HtmlController.class, hc)   // the class for injection + the mock instance
.load()
...

@yaskor
Copy link
Author

yaskor commented May 24, 2019

Hi @rbygrave

please forget my last comment, I was just plain stupid.

But there is a Problem when using:

...
new BootContext()
.withBean(EmailService.class, emailServiceMock)   // the class for injection + the mock instance
.load()
...

When the mocked bean itself is a singleton the injected dependencies are null :-(

lets say:

EmailService emailServiceMock = spy(EmailService.class);
doNothing().when(emailServiceMock).sendEmail(any(), any(), any());

BeanContext beanContext = new BootContext()
.withBean(EmailService.class, emailServiceMock) 
.load()

Now the beanContext uses the mock, but the mock itself has no injected objects:

@Singleton
public class EmailService {

    @Inject public ContentService contentService; //<-- this is null!

...

@rbygrave
Copy link
Contributor

When the mocked bean itself is a singleton the injected dependencies are null :-(

That is the expected behaviour in that ... we (code) is providing the emailServiceMock instance rather than DInject.

I'm not expecting mocks to also have dependency injection or at least dependency injection applied to them. Hmmm.

@yaskor
Copy link
Author

yaskor commented May 28, 2019

but without it, you can't mock controllers, services, repositories that use dependency injection...
There would be nullPointers everywhere.

Maybe a parameter to the withBean method would be alternative?
(3rd param is optional for applying dependency injection)

context.withBean(EmailService.class, emailServiceMock, true); 

@yaskor
Copy link
Author

yaskor commented May 28, 2019

Maybe your thinking about pure mocks - then it makes less sense to use dependency injection. But if you are using spyies, methods that are not mocked should work as expected.

Here is an example:

        BootContext bootContext = new BootContext();
        EmailService emailServiceSpy = spy(EmailService.class);
        doNothing().when(emailServiceSpy).sendEmail(anyString(), anyString(), anyString());
        bootContext.withBean(EmailService.class, emailServiceSpy);
        beanContext = bootContext.load();

In this Spy only the sendEmail method is "mocked" to prevent unwanted emails.
The other methods should work as expected...

@yaskor
Copy link
Author

yaskor commented May 29, 2019

A little addition:

I came accros this while migrating from spring there I use:

...
    @SpyBean private EmailService emailService;
    @SpyBean private VehicleService vehicleService;

    @Before
    public void setUp() throws Exception {
        doNothing().when(vehicleService).refresh(any());
        doNothing().when(emailService).sendEmail(any(), any(), any());
    }
...

The spyied beans all have there dependencies injected...

For Dinject, I would recomend the parameter option mentioned above OR if you are having strong conviction about this :-), Dinject could provide a method to "manually" do the injections -
Something like:

context.loadSingle(EmailService.class, emailServiceSpy)

@rbygrave
Copy link
Contributor

Right. So I'm having thoughts around ... DBuilder, builder.isAddBeanFor() and builder.register()

    if (builder.isAddBeanFor(..., ...)) {
      Foo bean = ...
      builder.register(bean, null, ...);
    }

... which gives us before and after the injection.

So the question would be should we have an alternate DBuilder that can do things like wrap bean with spy etc. A DBuilder we would probably only use in tests.

@rbygrave
Copy link
Contributor

Returning false on isAddBeanFor() is effectively for the 'pure mock' - test code providing a test double instance case.

@rbygrave
Copy link
Contributor

So for spy

  @Test
  public void withMockitoSpy_expect_spyUsed() {

    try (BeanContext context = new BootContext()
      .withSpy(Pump.class, pump -> {
        // do something interesting to setup the spy
        doNothing().when(pump).pumpWater();
      })
      .load()) {

      // Returns the mockito enhanced spy pump instance
      Pump pump = context.getBean(Pump.class);

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
      coffeeMaker.makeIt();

      // and we can spy verify 
      verify(pump).pumpWater();
    }
  }

and for mock

  @Test
  public void withMockitoMock_expect_mockUsed() {

    AtomicReference<Pump> mock = new AtomicReference<>();

    try (BeanContext context = new BootContext()
      .withMock(Grinder.class)
      .withMock(Pump.class, pump -> {
        // do something interesting to setup the mock
        mock.set(pump);
      })
      .load()) {

      Pump pump = context.getBean(Pump.class);
      assertThat(pump).isSameAs(mock.get());

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
    }
  }

@rbygrave
Copy link
Contributor

SPY - 3 setup options

No setup

  @Test
  public void withMockitoSpy_noSetup_expect_spyUsed() {

    try (BeanContext context = new BootContext()
      .withSpy(Pump.class)
      .load()) {

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
      coffeeMaker.makeIt();

      Pump pump = context.getBean(Pump.class);
      verify(pump).pumpWater();
    }
  }

Setup after beanContext.load()

  @Test
  public void withMockitoSpy_postLoadSetup_expect_spyUsed() {

    try (BeanContext context = new BootContext()
      .withSpy(Pump.class)
      .load()) {

      // setup after load()
      Pump pump = context.getBean(Pump.class);
      doNothing().when(pump).pumpWater();

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
      coffeeMaker.makeIt();

      verify(pump).pumpWater();
    }
  }

Setup inline ...

  @Test
  public void withMockitoSpy_expect_spyUsed() {

    try (BeanContext context = new BootContext()
      .withSpy(Pump.class, pump -> {
        // setup the spy
        doNothing().when(pump).pumpWater();
      })
      .load()) {

      Pump pump = context.getBean(Pump.class);

      CoffeeMaker coffeeMaker = context.getBean(CoffeeMaker.class);
      assertThat(coffeeMaker).isNotNull();
      coffeeMaker.makeIt();

      verify(pump).pumpWater();
    }
  }

rbygrave added a commit that referenced this issue May 30, 2019
This is enhancement of the instance with dependencies injected and then registering/using that enhanced instance
rbygrave added a commit that referenced this issue May 30, 2019
This is enhancement of the instance with dependencies injected and then registering/using that enhanced instance
@rbygrave
Copy link
Contributor

See #22

@rbygrave rbygrave modified the milestones: 1.5, 1.6 May 30, 2019
rbygrave added a commit that referenced this issue May 30, 2019
Provides a cleaner separation for handling mocks (supplied beans) and spy (enhancement)
rbygrave added a commit that referenced this issue May 30, 2019
Adding support for Mockito spy  (#20) …
@rbygrave
Copy link
Contributor

Right, merged in #22 ... releasing at version 1.6. We can close this for the moment and you can try version 1.6 and confirm that works as desired.

Cheers, Rob.

@yaskor
Copy link
Author

yaskor commented May 30, 2019

O wow, that was fast again.

@rbygrave thank you very much, with this I can finaly migrate completly from spring.

Ive spend some time to write a nice Junit5 extension, its small and fully configurable. If you are interested I would love to share it - maybe you can use it or enhance on it.

@rbygrave
Copy link
Contributor

@yaskor ... no problem - thanks for raising the issue, it's nice to have it sorted.

I'm pretty happy with how the internals improved and we have a nicer API for mocks and spy's now so win win - I'm really happy with the result !!

I've spend some time to write a nice Junit5 extension

Yeah that would be great. I haven't had a chance to even use Junit5 yet - crazy !!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants