1

originally had the code below

public class Sender {
    public void printLine() {
        System.out.println("print line");
    }
}


public class BSending {
    protected final Sender aSender;
    private final RandomNumGen randomNumGen;

    @Autowired
    public BSending(Sender sender, int thresh) {
        this.aSender = sender;
        this.randomNumGen = new RandomNumGen(thresh);
        //also injects other beans
    }

    public void sendTask() {
        if (this.randomNumGen.isChosen()) {
            aSender.printLine();
        }
    }
}

public class BSendingTest {
    @Mock private Sender aSender;
    @Mock private RandomNumGen randomNumGen;


    @Test
    void test() {
        when(randomNumGen.isChosen()).thenReturn(true);
        BSending bSending = new BSending(aSender, 25);
        bSending.sendTask();
        verify(aSender, times(1)).printLine();
    }
}


end2endTest {
    ..

    @SpringBootTest(
       classes = {
            BSending.class
       })

   ..

}

where randomNumGen.isChosen() uses a random-number generator to determine if it should return true or false, but it depends on the value thresh that is passed into BSending. This value is read from an input text file

Here, I expected aSender.printLine() to be hit, but its not. Its because the mocked randomNumGen isn't being used due to the new RandomNumGen(thresh)

EDIT:

If I then create an abstract class Conf so the now becomes

@Component
public abstract class Conf {
    public int thresh;
    public abstract RandomNumGen newRandomNumGen();

}

public class BSending {
    protected final Sender aSender;
    private final Conf conf;

    @Autowired
    public BSending(Sender sender, int thresh, Conf conf) {
        this.aSender = sender;
        this.conf = conf;
        this.conf.thresh = thresh;
    }

    public void sendTask() {
        if (conf.randomNumGen.isChosen()) {
            aSender.printLine();
        }
    }
}

public class BSendingTest {
    @Mock private Sender aSender;
    @Mock private RandomNumGen randomNumGen;
    @Mock private Conf conf;


    @Test
    void test() {
        when(conf.newRandomNumGen()).thenReturn(randomNumGen);
        when(randomNumGen.isChosen()).thenReturn(true);
        BSending bSending = new BSending(aSender, 25, conf);
        bSending.sendTask();
        verify(aSender, times(1)).printLine();
    }
}

where randomNumGen.isChosen() uses a random-number generator to determine if it should return true or false

Before, I expected aSender.printLine() to be hit, but its not. But with this new code with the abstract class Conf, now it works.

The problem now is that because Conf is an abstract class (I made it abstract because it has the thresh member and newRandomGen() method), the end2end test now fails with BeanInstantiationException is it an abstract class as it has

 @SpringBootTest(
   classes = {
        BSending.class, Conf.class
   })

Is there a way I can replicate the above without having to make Conf an abstract class?

EDIT2:

public class RandomNumGen {
    private final thresh;
    public RandomNumGen(int thresh) {
        this.thresh = thresh;
    }
    public boolean isChosen() {
        return isChosenX(this.thresh, new Random())
    }
    public boolean isChosenX(int thresh, Random random) {
        //determine if chosen using random number generator and thresh
    }
    
        
}
user5739619
  • 1,748
  • 5
  • 26
  • 40
  • Does this answer your question? [Why are my mocked methods not called when executing a unit test?](https://stackoverflow.com/questions/74027324/why-are-my-mocked-methods-not-called-when-executing-a-unit-test) – knittl Jun 27 '23 at 21:53
  • no it doesn't. It provided a temporary solution by using an abstract class with `newRandomNumGen()`, but now the end2end test fails because I can't instantiate a bean of abstract class – user5739619 Jul 01 '23 at 00:17
  • Why abstract? Inject your dependency in the constructor, switch it with a test dependency. Your bean is still a concrete implementation – knittl Jul 01 '23 at 12:01
  • Isn't the dependency already injected in the constructor. What do you mean switch it with a test dependency? – user5739619 Jul 02 '23 at 17:09
  • its abstract because `public abstract RandomNumGen newRandomNumGen();` has no implementation. I didn't want it to return `new RandomNumGen()`, like the `RandomFactory` in the link you provided. But I couldn't make it an interface because I need the parameter `thresh`. – user5739619 Jul 02 '23 at 17:09
  • Why do you have `conf` at all? Just do `public BSending(Sender sender, int thresh, RandomNumGen rand)` like in the linked question. Also, why is `thresh` preventing you from defining an interface? `setThresh(int thresh)` and `getThresh()` can live in an interface just fine. Having said that, I don't think you need your `Conf` class at all. `@Component` is something to put on your _concrete_ implementations, not on an abstract base. You must implement your interface/base class for your production code. – knittl Jul 02 '23 at 17:17
  • Because `BSending` needs to get `thresh` from an input text file so I can't use `RandomNumGen` as a parameter for the `BSendingService` constructor as it doesn't know yet the value of `thresh` – user5739619 Jul 02 '23 at 18:04
  • But `thresh` is not used by `RandomNumGen`? (Missing [mre]?) If it were, then it should be a parameter in the constructor of `RandomNumGen`. Your test impl would implement the same interface, but use a different constructor (parameterless or with fixed thresh). Maybe I'm not understanding or seeing your problem. I think your task ("how to test BSending with a fixed random generator") is perfectly solved by the many solutions given in the dupe question/answer. – knittl Jul 02 '23 at 18:14
  • I provided the code for `RandomNumGen` in EDIT2. Its from a 3rd-party library that I cant modify. – user5739619 Jul 02 '23 at 18:57
  • If I make `conf` an interface, then I can't inject a bean of it in `BSending` , right? I get no such bean error in the end2end test when I make it an interface – user5739619 Jul 02 '23 at 18:58
  • `BSending` needs to use `@Autowired` since it injects additional beans that I can't show the code for – user5739619 Jul 02 '23 at 19:15
  • Why woudln't you be able to inject a bean of the interface? `interface Conf { void whatever(); }` then `@Component class ConfBean implements Conf { @Override void whatever() { /* your impl */; } }` and finally `@Component class BSending { @Autowired BSending(Conf conf, ...) { ... } }`. If `RandomNumGen` cannot be modified directly, create an Adapter or a Decorator for it to suit your use case. – knittl Jul 02 '23 at 19:36
  • wouldn't `ConfBean` require implementing `RandomNumGen newRandomNumGen();`? The point of that is so I wouldn't have to implement it using `new RandomNumGen()` for `Delayed Construction via Factory` in the link you provided – user5739619 Jul 02 '23 at 20:00
  • Yes. And your `class TestConf implements Conf { ... }` would return whatever fake implementation you require in your tests. Then your `BSending` can simply call `conf.newRandomNumGen()` and get the real thing in production and in your test you instantiate as `new BSending(new TestConf())`, thus your class will get the fake random number generator. – knittl Jul 02 '23 at 21:13
  • your suggestion worked, thanks – user5739619 Jul 04 '23 at 04:39

2 Answers2

0

yes you are correct.

In your test, you are mocking the behavior of the RandomNumGen class using

when(randomNumGen.isChosen()).thenReturn(true);

This means that whenever randomNumGen.isChosen(); is called in the test, it should return true.

However, when you create a new instance of RandomNumGen using new RandomNumGen() inside the BSending class, it creates a different instance that is not aware of the mocking behavior set up in the test.

To fix this one way you can do is:

public class BSending {
    protected final Sender aSender;
    private final RandomNumGen randomNumGen;

    @Autowired
    public BSending(Sender sender, RandomNumGen randomNumGen) {
        this.aSender = sender;
        this.randomNumGen = randomNumGen;
    }

    public void sendTask() {
        if (randomNumGen.isChosen()) {
            aSender.printLine();
        }
    }
}

and then you can run your test.

Brooklyn99
  • 987
  • 13
  • 24
  • That makes sense. But how can I get this to work if `isChosen()` must continue to take no arguments but the result of `RandomNumGen.isChosen()` now depends on a parameter `thresh` and `BSending` must use this new parameter `thresh`? I edited the code above for this case where now `this.randomNumGen = new RandomNumGen(thresh);`. How can I fix the code in this case? – user5739619 Jun 26 '23 at 21:54
  • Also, how can I handle this if theres now 2 objects of RandomNumGen: aRandomNumGen and cRandomNumGen where I want isChosen() to be true for the former but false for the latter? – user5739619 Jun 26 '23 at 22:33
  • you can use when(randomNumGen.isChosen()).thenReturn(true).thenReturn(false); – Brooklyn99 Jun 27 '23 at 01:04
  • With that I still can't get `verify(aSender, times(1)).printLine();` to pass. Isn't there a way to get this to work while keeping `new RandomNumGen(thresh);`? – user5739619 Jun 27 '23 at 04:19
0

newing up instances in your class couples them to a single implementation. There are ways around it, which usually involves using PowerMockito or similar tools to intercept constructor calls and return a different implementation.

But you don't have to use such tools or libraries if you are willing to restructure your classes a little bit. If you do that, standard Java has got you covered.

For a proper discussion, let's look at the original code:

public class Sender {
    public void printLine() { System.out.println("print line"); }
}

public class BSending {
    protected final Sender aSender;
    private final RandomNumGen randomNumGen;

    @Autowired
    public BSending(Sender sender, int thresh) {
        this.aSender = sender;
        this.randomNumGen = new RandomNumGen(thresh);
        //also injects other beans
    }

    public void sendTask() {
        if (this.randomNumGen.isChosen()) aSender.printLine();
    }
}

public class BSendingTest {
    @Mock private Sender aSender;
    @Mock private RandomNumGen randomNumGen;

    @Test void test() {
        when(randomNumGen.isChosen()).thenReturn(true);
        BSending bSending = new BSending(aSender, 25);
        bSending.sendTask();
        verify(aSender, times(1)).printLine();
    }
}

As written above, this.randomNumGen = new RandomNumGen(thresh) tightly couples your class to this single random number generator implementation. Your @Mock RandomNumGen randomNumGen is never used by your class – how would it, you never pass it to the class and the class instantiates its own instance. While not the source of the problem, it is also worth pointing out that passing thresh to the BSending class is a bit odd too. BSending shouldn't know about the threshold for the number generator; the generator implementation should (and a different implementation might not even use a threshold).

You mention that RandomNumGen is a third-party class and you cannot change it. That requires a bit more work on our part, but this is mostly writing a bit of boilerplate.

First, let's start by introducing our own "random number" contract, which we'll consume in the service:

interface RandomChooser {
  boolean isChosen();
}

and a "production implementation", which wraps the third-party generator:

@Component // create bean elligible for autowiring
class RandomNumGenChooser implements RandomChooser {
  private final RandomNumGen random;
  public RandomNumGenChooser(final int thresh) {
    this.random = new RandomNumGen(thresh);
  }
  @Override public boolean isChosen() { return random.isChosen(); }
}

Once we have that, migrate the service to consume the new interface:

public class BSending {
    protected final Sender aSender;
    private final RandomChooser random;

    @Autowired
    public BSending(final Sender sender, final int thresh) {
        this.aSender = sender;
        this.random = new RandomNumGenChooser(thresh);
        //also injects other beans
    }

    public void sendTask() {
        if (random.isChosen()) aSender.printLine();
    }
}

In the next step, move instantiation of the dependency out of the constructor. Since the implementation was already marked with @Component, it is added as bean to the application context and will be picked up by the autowiring mechanism:

public class BSending {
    protected final Sender aSender;
    private final RandomChooser random;

    @Autowired
    public BSending(final Sender sender, final RandomChooser random) {
        this.aSender = sender;
        this.random = random;
        //also injects other beans
    }

    public void sendTask() {
        if (random.isChosen()) aSender.printLine();
    }
}

But there's a problem! Spring cannot create an instance of the bean, because Spring does not know the value of thresh. You have 2 options:

  1. Annotate the constructor parameter with @Value to read it from a config file or the environment
  2. Implement a factory which creates the correct instance for you:

The factory could look like this:

@Component
class RandomChooserFactory {
  RandomChooser randomChooser() {
    final int thresh = // obtain threshold
    return new RandomNumGenChooser(thresh);
  }
}

Okay, but how does that solve your problem? So far, we have only introduced additional interfaces, classes, and indirection. Correct, but all of that allows us to easily swap the RandomNumGenChooser with any implementation we desire in the tests.

One such possible implementation could look like this:

class StaticRandomChooser implements RandomChooser {
  private final boolean chosen;
  public StaticRandomChooser(final boolean chosen) { this.chosen = chosen; }
  @Override public boolean isChosen() { return chosen; }
}

and it doesn't even have to be a bean.

Using it in your test becomes trivial now:

public class BSendingTest {
    @Mock private Sender aSender;

    @Test void test() {
        final BSending bSending = new BSending(
            aSender,
            new StaticRandomChooser(true));
        bSending.sendTask();
        verify(aSender).printLine();
    }
}

This solution and variants for similar problems are explained in Why are my mocked methods not called when executing a unit test?

knittl
  • 246,190
  • 53
  • 318
  • 364