Verdict
Yes
Test a void method that changes state or sends something out: call it, then assert on the effect a caller can observe, such as the saved record, the new state of the object, or the message a fake receives.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Test a void method that changes state or sends something out, and assert on that effect. In the typical case, a service method that changes an object and saves it, Blast radius is users and Change frequency is regularly, because customers see the wrong data and service methods change about once a month. Detectability is eventually and Reversibility is with-effort: a void method returns nothing and throws nothing when its effect goes missing, and records saved in the wrong state need a repair script. Test cost is moderate, since the test needs an in-memory repository or a fake to read the effect back. Rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The void method is a setter that only assigns one field | Do not test the setter; test the code that reads the value | Detectability moves to immediately and Reversibility to trivial, because tests that read the field fail when the assignment breaks |
| The void method charges a card or credits a customer's balance | Test mandatory: assert the amount and account a fake payment gateway receives, and that a retry does not charge twice | Blast radius rises to money and Reversibility to costly |
| The void method erases a customer's personal data on a deletion request | Test mandatory: assert that every store that held the data returns nothing for that customer | Blast radius rises to safety-or-legal, Detectability to never and Reversibility to impossible |
| The void method is a one-time backfill that updates existing rows | Test differently: run the backfill on a copy of production data and check the rows first | Change frequency falls to once, so a test in the suite would never run again |
| The void method writes to a legacy system through a static client that a test cannot replace | Test differently: alert when the expected records stop arriving in the legacy system | Test cost rises to heavy, while Detectability stays eventually and Reversibility with-effort |
| A mocking library turns verify(repository).save(any()) into a one-line test of the void method | Test: capture the saved object and assert on its fields | Test cost falls to trivial, but Detectability stays eventually, because a check that save ran passes when the saved object is wrong |
What breaks if you don't test
A customer turns off SMS reminders, and updateNotificationSettings() flips the flag on the object. A refactor moves the save() call into a branch that runs only when the email address changes. The method returns normally, the page shows "Saved", and CI stays green because no test reads the stored setting. The texts keep coming, the customer complains a week later, and every account whose setting changed since the release needs a repair script.
What you lose if you over-test
A void method invites tests that verify every collaborator call in order, ending with verifyNoMoreInteractions. Such a test restates the method line by line and fails when someone reorders the calls, even though customers see the same result. A bare verify(repository).save(any()) fails in the other direction: it passes when the method saves the wrong object.
How to test
Write unit tests that call the method and then read the effect the way a caller would: the object's state through its getters, the stored record through an in-memory repository, or the outgoing message through a fake mailer or queue. The minimum set is one test of the main path and one test per rule that decides whether the effect happens. Check state rather than calls, as the Test Doubles chapter of Software Engineering at Google recommends. When a mock is the only option, capture the argument and assert on its fields.
Procedure and references
When the answer changes
- The method moves money or changes who can see personal data.
- The method only assigns a field that tested code reads.
- The effect lands in a system your test cannot replace without a refactor.
Code example
Asserting on the effect, not the call
This service method cancels an order and emails the customer:
void cancel(OrderId id) {
Order order = orders.find(id);
order.markCancelled(clock.instant());
orders.save(order);
mailer.send(Emails.cancellation(order));
}
@Test
void cancelMarksTheOrderAndEmailsTheCustomer() {
var orders = new InMemoryOrders(List.of(openOrder("A-17")));
var mailer = new FakeMailer();
var service = new OrderService(orders, mailer, fixedClock());
service.cancel(new OrderId("A-17"));
assertEquals(CANCELLED, orders.find(new OrderId("A-17")).status());
assertEquals(List.of("A-17"), mailer.sentOrderIds());
}
Delete the markCancelled line and this test fails, while verify(orders).save(any()) still passes, because the method saves the unchanged order. Swap the save and the email, and this test stays green, because the customer sees the same result.
Related questions
FAQ
- Should I write unit tests for void methods?
Yes, write unit tests for a void method that changes state or sends a message, and assert on that effect instead of a return value. Skip a void setter that only assigns a field, because tests of the code that reads the field fail when the assignment breaks.
- How do you unit test a method that returns nothing?
Call the method, then read its effect the way a caller would: the object's new state through a getter, the saved record through an in-memory repository, or the sent message through a fake. The effect is the result, so the test needs no return value.
- Should I use Mockito verify to test a void method?
Use Mockito
verifyonly when the effect leaves your process and no fake can record it, and then capture the argument with anArgumentCaptorand assert on its fields. A bareverify(repository).save(any())passes when the method saves the wrong object.- Are void methods harder to test?
No, a test of a void method needs one extra step, reading the effect back, and that step is cheap when the method gets its collaborators through the constructor. A void method that writes through a static client or a global singleton stays hard to test until that dependency becomes a parameter.