i normally associate that term with types that allow null as a value. this seems like it refers to types that have some null/unusable fields when constructed for testing?
At the risk of introducing yet another bit of jargon to the testing world, why not call them “nerfed” objects? It’s the real thing, just covered with a soft layer of foam. :-)
Names are hard. It’s named after the Null Object pattern, which was the inspiration, which you can also see in things like .NET’s NullLogger. Things are “Nullable” when they can be constructed in a “Nulled” state where they don’t talk to the outside world.
The pattern language is designed specifically so tool support isn’t needed. Instead, the low-level Nullables are highly reusable, and I have vague plans to release libraries of reusable low-level Nullables. If it catches on, I could see people making their APIs nullable.
These had a different name in the past, Fakes. The concept is important though and more people should be aware of it. I can’t even count the number of times that I debugged problems that weren’t caught by a test because it was testing interactions instead of logic/implementations. The converse is also true. Tests that break after refactors even though no bug was introduced.
Fakes or Nullables as you call them keep your tests from being too flaky and also catch more real breakages.
Came here to post basically the same thing. “Nullables” are basically just Fakes or Stubs.
Though, I can understand why one might want to call it something besides “Fake”, because the spirit of the technique is that the implementation is “real” as far as it can possibly be, and only “nulled” or stubbed at the point of interacting with the outside world.
The other difference with “Nullables”, which I’ve gathered from other posts on the subject, is that the Nullable implementation actually exists in the production code, and can often be one and the same as the production implementation. This as opposed to Mocks/Fakes/Stubs which tend to be written as part of the test code, and therefore are maintained separately from the production implementation code.
Yes, at the lowest level, a Nullable is a production module with an embedded Fake or Stub. (Usually a stub, because they’re easier to write and maintain.) Some people call Nullables “production doubles” in contrast to Mocks/Spies/Fakes/Stubs, which are “test doubles.”
Nullables have two big advantages over traditional Fakes and Stubs. First is that you stub the third-party library, not your own code. Fakes and stubs are usually written as a replacement for your code. The Nullable approach means that your tests are running all the code you wrote, so you catch more errors when things change.
(Fakes are more likely to be written as a replacement for the third-party code, but they’re harder to write than stubs. You have to match a lot of existing behavior.)
The other advantage of Nullables over traditional Fakes and Stubs is the emphasis on encapsulation. Although they’re implemented with an embedded Fake/Stub at the lowest level, the conception of the Nullable is that it’s “production code with an infrastructure off switch.” This applies at every level of the call stack. So your low-level infrastructure wrapper is Nullable, but so is your high-level infrastructure wrapper and your application code. With a Fake/Stub, you typically have to worry about low-level details in tests of your high-level code. With Nullables, you only worry about the direct dependencies of your current unit under test.
This emphasis on encapsulation makes a lot of little things easier. You don’t have to worry about deep dependency injection, usability is better, there’s production use cases, reuse is simple. Implementing Nullables is easy, too, because you’re typically just delegating to lower-level Nullables. The lowest-level Nullables—the ones with the embedded Stub/Fake—are highly reusable.
These benefits do come at a cost—your production code now has a createNull() factories that may not be used in production, and your low-level infrastructure has an embedded fake or stub that may not be used in production. For some people, that’s a deal-breaker, but I think the tradeoffs are worth it.
Nullables have two big advantages over traditional Fakes and Stubs. First is that you stub the third-party library, not your own code. Fakes and stubs are usually written as a replacement for your code. The Nullable approach means that your tests are running all the code you wrote, so you catch more errors when things change.
That’s a good point, and I’m not sure if I realized that this distinction is an explicit part of Nullables or not.
It so happens that I’ve been writing my Stubs like this already for a couple of years now. I haven’t used Mocks in a long time because I believe Mocks almost always test the wrong thing: you should be testing that your code is producing the correct result, not that its implementation happens to call some other code a certain way. I also don’t use Fakes because then how do you know that your Fake is implemented correctly? Do you test your Fake somehow before you use your Fake to test something else?
So, I only use Stubs if I can help it, and I only Stub out code that communicates with the outside world.
With your approach to Nullables, what do you do for interfaces that are supposed to return a value, like an HTTP API? Do you just hard code one response in your Nullable, or do you somehow make your Nullable configurable via the factory functions? When I’m working with APIs like that, I use boring old inversion-of-control (a.k.a. dependency-injection without a “DI framework”) so that I can write a custom Stub for a given test case. The downside is, of course, that the inversion-of-control causes the dependency to propagate through all of the code that uses the interface.
The createNull() factory is configurable, and exists (at varying levels of abstraction) in every intermediate dependency. It decomposes the configuration for the next level down. This prevents the problem of having to propagate a low-level Stub in a high-level test, because the createNull() factories do the propagation for you.
Consider my DB layer: when I create a Nullable for functionality that normally requires the DB, how does that Nullable work? I want to test functionality that both reads and writes things to the DB. How do I do that in a way that doesn’t require my test code to know the details about how it’s going to access the DB?
If we’re at a low level, of course, this isn’t so bad. The Nullable works like a standard fake–I give it a set response that it returns when I run my SQL (or my ORM query). Maybe the value is limited, but it clearly works.
But what about in a high level case, where I’m testing code that calls into something that calls into the DB? It feels like either:
I have a configured response at the highest level, in which case I’ve recreated a normal mock
The calls go through all the intervening layers, at which point I have to break encapsulation, and inject configured responses for all the intervening objects–my test knows too much.
I could avoid configuring responses by writing to something like an in-memory database, which works, but doesn’t sound like a Nullable, and brings its own challenges.
In other words, I can see how this works for
“fire and forget” type objects (stdout, a logger, etc)
direct stateful dependencies of the code under test
but how it works for indirect dependencies that maintain their own state isn’t obvious.
The part you’re missing is the focus on encapsulation. The intervening layers are also Nullable, and are implemented by delegating to their Nullable dependencies. Your tests only ever concern themselves with the unit under test and its direct dependencies. Lower-level details are encapsulated.
That example includes a web server that talks to a ROT-13 microservice. When the user enters data into a form, the browser makes a POST request. The POST request is handled by WwwRouter, which delegates to HomePageController, which delegates to Rot13Client, which uses HttpClient to make a POST request to the service.
In this example, HomePageController is analogous to your high-level code that calls into something that calls into the DB. HomePageController depends on Rot13Client, but it’s unaware of HttpClient, which is encapsulated by Rot13Client.
it("POST renders result of ROT-13 service call", async () => {
const { response } = await postAsync({ rot13Response: "my_response" });
assert.deepEqual(response, homePageView.homePage("my_response"));
});
In those tests, postAsync() is doing a lot of heavy lifting, and it’s a bit confusing if you’re not familiar with the patterns. Here’s a simplified and annotated version with only what’s needed for the above test:
async function postAsync({ rot13Response }) {
// Create the Nulled Rot13Client
const rot13Client = Rot13Client.createNull([{ response: rot13Response }]);
// Create a Nulled HTTP request
const request = HttpServerRequest.createNull({ body: IRRELEVANT_BODY });
// Create a few more instances—they're not used in this test, but they're required parameters
const clock = Clock.createNull(); // needed for constructor
const config = WwwConfig.createTestInstance(); // needed to simulate POST request
// Create the unit under test
const controller = new HomePageController(rot13Client, clock);
// Simulate a POST request
const response = await controller.postAsync(request, config);
return { response };
}
The important part of the above is that the HomePageController tests don’t know about HttpClient. Instead, they configure a Nulled Rot13Client. That’s how your DB example would work, too.
You’re probably wondering how Rot13Client.createNull() is implemented. It encapsulates HttpClient. Here’s a simplified and annotated version:
static createNull({ response = "Nulled Rot13Client response" }) {
// Define the response the HTTP client should return. This is the actual response the real ROT-13 client
// would return. It needs to be real because Nullables run real production code.
const response = {
status: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({ transformed: response });
};
// Create the Nulled HttpClient, telling it to return the above response when the /rot13/transform endpoint
// is called
const httpClient = HttpClient.createNull({
"/rot13/transform": response,
});
// Create the Rot13Client and inject the Nulled HttpClient.
return new Rot13Client(httpClient);
}
So again, the piece you were missing was encapsulation—you don’t just make the database wrapper Nullable, you make the intermediate layers Nullable too, and they encapsulate the lower layers. In this example, HomePageController’s tests didn’t have to know about HttpClient because it could create a Nulled Rot13Client instead. Then Rot13Client was responsible for making the Nulled HttpClient, using the knowledge of the ROT-13 API only it had.
I was certainly clear that you use nullables all the way down.
But my question was: what about non-trivial dependencies? It looks like the example you give works because each layer just passes on the data from the layer below it, doing very minimal transitions.
I don’t have an ideal example, but say we’re testing the ExpiredUserCleanupJob. It has to get all the expired users, then send an email to each of them.
It uses the UserService#getExpiredUsers method. As a nullable, we tell it that we want to return 5 users. Good so far.
Except, that here’s the implementation of the UserService method:
getExpiredUsers() {
let expiredUsers = userRepository.getExpiredUsers();
// ugh, we have a separate system for tracking promotional users from FooCorp we have to make a call here
// they say we'll fix this someday after logBungler lands
let promotionalUsers = fooCorpService.getPromotionalUsers();
return expiredUsers.filter(user -> {
if (!promotionalUsers.containsKey(user)) { throw new Exception("Oh no, data-mismatch"); }
return !promotionalUsers.get(user).hasPromotion();
}
}
In this case, we can handle this by using dependency injection to have an intelligent stub for the repository and fooCorpService, we can handle it by mocking the UserService. But any time we’re exercising our actual code, there are non-trivial dependencies between its dependencies, and I don’t get how the Nullable approach handles that.
There are two answers for this scenario, depending on whether you have legacy code or not. But let’s assume that your code has already been designed to use Nullables throughout. In that case, then both UserRepository and FooCorpService are also Nullable.
When you test UserService.getExpiredUsers(), you will of course test that it uses UserRepository and FooCorpService correctly. But callers of getExpiredUsers() don’t need to worry about that. The test just specifies the expired users:
it("sends emails to expired users", () => {
// Arrange
const userService = UserService.createNull({
expiredUsers: [ "a", "b", "c", "d", "e" ],
});
// We use the Output Tracker pattern to check which emails are sent
const emailService = EmailService.createNull();
const sentEmails = emailService.trackSentEmails();
// Act
const cleanupJob = new ExpiredUsersCleanupJob(userService, emailService);
cleanupJob.runJob();
// Assert
assertEmailsSent(sentEmails.data, /* ... */);
});
The implementation of UserService.createNull() delegates to UserService’s dependencies: The design of the createNull() parameters is up to you, and the best approach depends on everything else you care about. But let’s assume callers want the ability to specify expired users, non-expired users, and promotional users.
static createNull({ expiredUsers = [], nonExpiredUsers = [], promotionalUsers = [] }) {
// Set up the UserRepository dependency
const userRepository = UserRepository.createNull({
users: [ ...createExpiredUsers(expiredUsers), ...createNonExpiredUsers(nonExpiredUsers, promotionalUsers) ],
});
// Set up the FooCorpService dependency
const fooCorpService = FooCorpService.createNull({
promotionalUsers: createFooCorpUsers(promotionalUsers),
});
// Instantiate UserService
return new UserService(userRepository, fooCorpService);
}
The important part of the question was that there is an internal invariant that’s violated by the naive nullable and which results in an Exception being thrown if you don’t control the output of the UserRepository/FooCorpService.
I see how your solution of passing expiredUsers, nonExpiredUsers, promotionalUsers works, but it seems to undermine the idea that Nullables preserve encapsulation, at least in the presence of data-dependencies that are maintained by external services.
It’s the responsibility of the createNull() factory to preserve encapsulation, including the invariant you’re talking about. I didn’t put that in the example because I didn’t realize it was important to you, and I was trying to keep the example small.
Regarding that invariant, it looks like every user needs to be in FooCorpService, and marked as promotional or not? That seems straightforward enough, so I must still be misunderstanding you.
Just in case I’m not, here’s a revised example that’s a bit more thorough:
static createNull({ expiredUsers = [], nonExpiredUsers = [], promotionalUsers = [] }) {
const allUsers = union(expiredUsers, nonExpiredUsers, promotionalUsers);
// Set up the UserRepository dependency
const userRepository = UserRepository.createNull({
users: createRepositoryUsers(allUsers, promotionalUsers),
});
// Set up the FooCorpService dependency
const fooCorpService = FooCorpService.createNull({
users: createFooCorpUsers(allUsers, expiredUsers),
});
// Instantiate UserService
return new UserService(userRepository, fooCorpService);
}
static #createFooCorpUsers(allUsers, promotionalUsers) {
const fooCorpUsers = allUsers.map((user) => {
return {
userName: user,
hasPromotion: promotionalUsers.includes(user),
// other FooCorp-specific fields
};
});
}
static #createRepositoryUsers(allUsers, expiredusers) {
return allUsers.map(user => {
return {
userName: user,
expired: expiredUsers.includes(user), // assumes it doesn't matter to consumers if promotional users are expired or not
// other Repository-specific fields
};
});
}
I sort of see how that works for one method (you arrange the dependencies so that the data they return obeys the classes internal invariants), but I can’t shake the thought that when your service is called in many different ways, by different callers, that will impose different requirements on the data returned from its nullable dependencies.
To take an even more contrived example, what if one method on UserService requires it work with a set of users that aren’t in FooCorp?
I also am wondering in what sense it’s sticking to encapsulation to have the test code for the ExpiredUserCleanupJob inject promotional users into the UserService? The Job doesn’t know about promotional users, so should it be passing them into the test? Again, somewhat contrived, but let’s stipulate that promotional users aren’t even part of the public API of the UserService.
I’ve had the same concern, but so far, it hasn’t been a problem. The trick is to not think of it like a mock or spy, or even a traditional stub, where you’re setting up return values for specific method calls. Instead, think of it declaratively. When you design the createNull() signature, think about what the class you’re nulling fundamentally represents and how it exposes that representation to callers. Then design createNull() so that representation can be configured.
Part of what makes it work is to use optional named parameters. (Or a Builder, in languages without optional named parameters.) This allows you to have createNull() support a wide variety of configurations without requiring them to be specified in the tests. This answers your second question: If ExpiredUserCleanupJob doesn’t care about promotional users, its tests don’t specify them. See my original test example:
const userService = UserService.createNull({
expiredUsers: [ "a", "b", "c", "d", "e" ],
// nonExpiredUsers and promotionalUsers aren't specified, because they aren't relevant to this test
});
Similarly, if promotional users are entirely encapsulated, and invisible to all clients of UserService, then they wouldn’t be part of the createNull() signature. They’d be an implementation detail—tested by the UserService tests, but ignored by all consumers of UserService.
why overload the term “nullable”?
i normally associate that term with types that allow null as a value. this seems like it refers to types that have some null/unusable fields when constructed for testing?
At the risk of introducing yet another bit of jargon to the testing world, why not call them “nerfed” objects? It’s the real thing, just covered with a soft layer of foam. :-)
I love it :-) but at this point the name has been around for 7 years, and I think it would be more confusing to change it.
Names are hard. It’s named after the Null Object pattern, which was the inspiration, which you can also see in things like .NET’s NullLogger. Things are “Nullable” when they can be constructed in a “Nulled” state where they don’t talk to the outside world.
to be clear, this “Nullable” thing is a design pattern, not a library/framework, right?
(though, it seems like dependency injection, or special variables, or aspect-oriented hooks/detours would help a lot in implementing it)
Yes, it’s a design pattern, and part of my “Testing Without Mocks” pattern language. The home page is here:
https://www.jamesshore.com/s/nullables
The pattern language is designed specifically so tool support isn’t needed. Instead, the low-level Nullables are highly reusable, and I have vague plans to release libraries of reusable low-level Nullables. If it catches on, I could see people making their APIs nullable.
These had a different name in the past, Fakes. The concept is important though and more people should be aware of it. I can’t even count the number of times that I debugged problems that weren’t caught by a test because it was testing interactions instead of logic/implementations. The converse is also true. Tests that break after refactors even though no bug was introduced.
Fakes or Nullables as you call them keep your tests from being too flaky and also catch more real breakages.
Came here to post basically the same thing. “Nullables” are basically just Fakes or Stubs.
Though, I can understand why one might want to call it something besides “Fake”, because the spirit of the technique is that the implementation is “real” as far as it can possibly be, and only “nulled” or stubbed at the point of interacting with the outside world.
The other difference with “Nullables”, which I’ve gathered from other posts on the subject, is that the Nullable implementation actually exists in the production code, and can often be one and the same as the production implementation. This as opposed to Mocks/Fakes/Stubs which tend to be written as part of the test code, and therefore are maintained separately from the production implementation code.
Yes, at the lowest level, a Nullable is a production module with an embedded Fake or Stub. (Usually a stub, because they’re easier to write and maintain.) Some people call Nullables “production doubles” in contrast to Mocks/Spies/Fakes/Stubs, which are “test doubles.”
Nullables have two big advantages over traditional Fakes and Stubs. First is that you stub the third-party library, not your own code. Fakes and stubs are usually written as a replacement for your code. The Nullable approach means that your tests are running all the code you wrote, so you catch more errors when things change.
(Fakes are more likely to be written as a replacement for the third-party code, but they’re harder to write than stubs. You have to match a lot of existing behavior.)
The other advantage of Nullables over traditional Fakes and Stubs is the emphasis on encapsulation. Although they’re implemented with an embedded Fake/Stub at the lowest level, the conception of the Nullable is that it’s “production code with an infrastructure off switch.” This applies at every level of the call stack. So your low-level infrastructure wrapper is Nullable, but so is your high-level infrastructure wrapper and your application code. With a Fake/Stub, you typically have to worry about low-level details in tests of your high-level code. With Nullables, you only worry about the direct dependencies of your current unit under test.
This emphasis on encapsulation makes a lot of little things easier. You don’t have to worry about deep dependency injection, usability is better, there’s production use cases, reuse is simple. Implementing Nullables is easy, too, because you’re typically just delegating to lower-level Nullables. The lowest-level Nullables—the ones with the embedded Stub/Fake—are highly reusable.
These benefits do come at a cost—your production code now has a createNull() factories that may not be used in production, and your low-level infrastructure has an embedded fake or stub that may not be used in production. For some people, that’s a deal-breaker, but I think the tradeoffs are worth it.
That’s a good point, and I’m not sure if I realized that this distinction is an explicit part of Nullables or not.
It so happens that I’ve been writing my Stubs like this already for a couple of years now. I haven’t used Mocks in a long time because I believe Mocks almost always test the wrong thing: you should be testing that your code is producing the correct result, not that its implementation happens to call some other code a certain way. I also don’t use Fakes because then how do you know that your Fake is implemented correctly? Do you test your Fake somehow before you use your Fake to test something else?
So, I only use Stubs if I can help it, and I only Stub out code that communicates with the outside world.
With your approach to Nullables, what do you do for interfaces that are supposed to return a value, like an HTTP API? Do you just hard code one response in your Nullable, or do you somehow make your Nullable configurable via the factory functions? When I’m working with APIs like that, I use boring old inversion-of-control (a.k.a. dependency-injection without a “DI framework”) so that I can write a custom Stub for a given test case. The downside is, of course, that the inversion-of-control causes the dependency to propagate through all of the code that uses the interface.
The createNull() factory is configurable, and exists (at varying levels of abstraction) in every intermediate dependency. It decomposes the configuration for the next level down. This prevents the problem of having to propagate a low-level Stub in a high-level test, because the createNull() factories do the propagation for you.
A thing I can’t tell here, even after looking at the pattern language post (https://www.jamesshore.com/v2/projects/nullables/testing-without-mocks#configurable-responses) is what Nullables would look like in practice.
Consider my DB layer: when I create a Nullable for functionality that normally requires the DB, how does that Nullable work? I want to test functionality that both reads and writes things to the DB. How do I do that in a way that doesn’t require my test code to know the details about how it’s going to access the DB?
If we’re at a low level, of course, this isn’t so bad. The Nullable works like a standard fake–I give it a set response that it returns when I run my SQL (or my ORM query). Maybe the value is limited, but it clearly works.
But what about in a high level case, where I’m testing code that calls into something that calls into the DB? It feels like either:
In other words, I can see how this works for
The part you’re missing is the focus on encapsulation. The intervening layers are also Nullable, and are implemented by delegating to their Nullable dependencies. Your tests only ever concern themselves with the unit under test and its direct dependencies. Lower-level details are encapsulated.
I’ll use this example code to demonstrate: https://github.com/jamesshore/testing-without-mocks-complex
That example includes a web server that talks to a ROT-13 microservice. When the user enters data into a form, the browser makes a POST request. The POST request is handled by WwwRouter, which delegates to HomePageController, which delegates to Rot13Client, which uses HttpClient to make a POST request to the service.
In this example, HomePageController is analogous to your high-level code that calls into something that calls into the DB. HomePageController depends on Rot13Client, but it’s unaware of HttpClient, which is encapsulated by Rot13Client.
The tests of HomePageController are similarly aware of Rot13Client, but they’re unaware of HttpClient. Here’s an example test from https://github.com/jamesshore/testing-without-mocks-complex/blob/javascript/src/www/home_page/_home_page_controller_test.js:
In those tests, postAsync() is doing a lot of heavy lifting, and it’s a bit confusing if you’re not familiar with the patterns. Here’s a simplified and annotated version with only what’s needed for the above test:
The important part of the above is that the HomePageController tests don’t know about HttpClient. Instead, they configure a Nulled Rot13Client. That’s how your DB example would work, too.
You’re probably wondering how Rot13Client.createNull() is implemented. It encapsulates HttpClient. Here’s a simplified and annotated version:
So again, the piece you were missing was encapsulation—you don’t just make the database wrapper Nullable, you make the intermediate layers Nullable too, and they encapsulate the lower layers. In this example, HomePageController’s tests didn’t have to know about HttpClient because it could create a Nulled Rot13Client instead. Then Rot13Client was responsible for making the Nulled HttpClient, using the knowledge of the ROT-13 API only it had.
I was certainly clear that you use nullables all the way down.
But my question was: what about non-trivial dependencies? It looks like the example you give works because each layer just passes on the data from the layer below it, doing very minimal transitions.
I don’t have an ideal example, but say we’re testing the
ExpiredUserCleanupJob
. It has to get all the expired users, then send an email to each of them.It uses the
UserService#getExpiredUsers
method. As a nullable, we tell it that we want to return 5 users. Good so far.Except, that here’s the implementation of the
UserService
method:In this case, we can handle this by using dependency injection to have an intelligent stub for the repository and fooCorpService, we can handle it by mocking the UserService. But any time we’re exercising our actual code, there are non-trivial dependencies between its dependencies, and I don’t get how the Nullable approach handles that.
There are two answers for this scenario, depending on whether you have legacy code or not. But let’s assume that your code has already been designed to use Nullables throughout. In that case, then both UserRepository and FooCorpService are also Nullable.
When you test UserService.getExpiredUsers(), you will of course test that it uses UserRepository and FooCorpService correctly. But callers of getExpiredUsers() don’t need to worry about that. The test just specifies the expired users:
The implementation of UserService.createNull() delegates to UserService’s dependencies: The design of the createNull() parameters is up to you, and the best approach depends on everything else you care about. But let’s assume callers want the ability to specify expired users, non-expired users, and promotional users.
The important part of the question was that there is an internal invariant that’s violated by the naive nullable and which results in an Exception being thrown if you don’t control the output of the UserRepository/FooCorpService.
I see how your solution of passing expiredUsers, nonExpiredUsers, promotionalUsers works, but it seems to undermine the idea that Nullables preserve encapsulation, at least in the presence of data-dependencies that are maintained by external services.
It’s the responsibility of the createNull() factory to preserve encapsulation, including the invariant you’re talking about. I didn’t put that in the example because I didn’t realize it was important to you, and I was trying to keep the example small.
Regarding that invariant, it looks like every user needs to be in FooCorpService, and marked as promotional or not? That seems straightforward enough, so I must still be misunderstanding you.
Just in case I’m not, here’s a revised example that’s a bit more thorough:
Does that address your concern?
I sort of see how that works for one method (you arrange the dependencies so that the data they return obeys the classes internal invariants), but I can’t shake the thought that when your service is called in many different ways, by different callers, that will impose different requirements on the data returned from its nullable dependencies.
To take an even more contrived example, what if one method on UserService requires it work with a set of users that aren’t in FooCorp?
I also am wondering in what sense it’s sticking to encapsulation to have the test code for the
ExpiredUserCleanupJob
inject promotional users into theUserService
? The Job doesn’t know about promotional users, so should it be passing them into the test? Again, somewhat contrived, but let’s stipulate that promotional users aren’t even part of the public API of the UserService.I’ve had the same concern, but so far, it hasn’t been a problem. The trick is to not think of it like a mock or spy, or even a traditional stub, where you’re setting up return values for specific method calls. Instead, think of it declaratively. When you design the createNull() signature, think about what the class you’re nulling fundamentally represents and how it exposes that representation to callers. Then design createNull() so that representation can be configured.
Part of what makes it work is to use optional named parameters. (Or a Builder, in languages without optional named parameters.) This allows you to have createNull() support a wide variety of configurations without requiring them to be specified in the tests. This answers your second question: If ExpiredUserCleanupJob doesn’t care about promotional users, its tests don’t specify them. See my original test example:
Similarly, if promotional users are entirely encapsulated, and invisible to all clients of UserService, then they wouldn’t be part of the createNull() signature. They’d be an implementation detail—tested by the UserService tests, but ignored by all consumers of UserService.
Hey y’all, author here. Happy to answer any questions you might have.