commit 8fc8bc934fdf31f28e4b7bcab4c009ad84fbbc65 Author: asmhatre Date: Sat Oct 24 17:16:28 2026 +0000 mockito: Mockito 5 lab (inline mock maker, static/construction mocking, captors, BDD, strict stubs) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Cfo2K4u4rF18QsPXPFnytf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a51eb6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Ankur Mhatre + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b8beb0 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# java-testing-lab + +Runnable companion code for the testing posts on [ankurm.com](https://ankurm.com). + +| Module | Post | Stack | +|---|---|---| +| [`mockito/`](mockito) | Mockito 5 Deep Dive: Static, Final and Constructor Mocking, ArgumentCaptor and Strict Stubs | Mockito 5.24.0, JUnit 6.1.3, JDK 25 | + +Every module keeps the real output of its tests in `output/`. Each test writes the transcript and asserts the same facts, so the numbers in the posts fail the build if they stop being true. + +```bash +cd mockito && ./scripts/run-all.sh +``` diff --git a/mockito/output/01-mock-maker.txt b/mockito/output/01-mock-maker.txt new file mode 100644 index 0000000..7f3b686 --- /dev/null +++ b/mockito/output/01-mock-maker.txt @@ -0,0 +1,7 @@ +default MockMaker : org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMaker +PriceCalculator is final : true +mock class name : lab.mockito.PriceCalculator +mock class == real class : true +stubbed final method : applyDiscount(1000, 10) = 42 +unstubbed final method : applyDiscount(1000, 20) = 0 +mocked a record : true diff --git a/mockito/output/02-mock-static.txt b/mockito/output/02-mock-static.txt new file mode 100644 index 0000000..df7dd2c --- /dev/null +++ b/mockito/output/02-mock-static.txt @@ -0,0 +1,4 @@ +before scope : id starts with ORD- : true +inside scope : place("alice") = ORD-FIXED@alice +inside scope : unstubbed Ids.nowMillis() = 0 (default answer, not the clock) +after scope : id starts with ORD- and is not FIXED : true diff --git a/mockito/output/03-static-thread-local.txt b/mockito/output/03-static-thread-local.txt new file mode 100644 index 0000000..29af223 --- /dev/null +++ b/mockito/output/03-static-thread-local.txt @@ -0,0 +1,2 @@ +test thread sees : ORD-FIXED +other thread sees : real id, not FIXED = true diff --git a/mockito/output/04-static-already-registered.txt b/mockito/output/04-static-already-registered.txt new file mode 100644 index 0000000..ae407f0 --- /dev/null +++ b/mockito/output/04-static-already-registered.txt @@ -0,0 +1,4 @@ +MockitoException: + For lab.mockito.Ids, static mocking is already registered in the current thread + + To create a new mock, the existing static mock registration must be deregistered diff --git a/mockito/output/04b-static-calls-real-methods.txt b/mockito/output/04b-static-calls-real-methods.txt new file mode 100644 index 0000000..10b0853 --- /dev/null +++ b/mockito/output/04b-static-calls-real-methods.txt @@ -0,0 +1,2 @@ +newOrderId() = ORD-FIXED +nowMillis() is the real clock (> 0) : true diff --git a/mockito/output/04c-static-mock-system.txt b/mockito/output/04c-static-mock-system.txt new file mode 100644 index 0000000..56e963f --- /dev/null +++ b/mockito/output/04c-static-mock-system.txt @@ -0,0 +1,2 @@ +MockitoException: + It is not possible to mock static methods of java.lang.System to avoid interfering with class loading what leads to infinite loops diff --git a/mockito/output/05-mock-construction.txt b/mockito/output/05-mock-construction.txt new file mode 100644 index 0000000..3c84650 --- /dev/null +++ b/mockito/output/05-mock-construction.txt @@ -0,0 +1,6 @@ +no interception : real SMTP connection attempted to smtp.example.com +constructor args : [smtp.example.com] +construction # : 1 +welcome("bob") : true +instances built : 1 +verified : send("bob@example.com", "Welcome, bob") diff --git a/mockito/output/06-argument-captor.txt b/mockito/output/06-argument-captor.txt new file mode 100644 index 0000000..7e46c6c --- /dev/null +++ b/mockito/output/06-argument-captor.txt @@ -0,0 +1,3 @@ +charged amounts : [900, 500] +last captured : 500 +audit details : [acc-9:900, acc-7:500] diff --git a/mockito/output/07-captor-empty.txt b/mockito/output/07-captor-empty.txt new file mode 100644 index 0000000..5b96a85 --- /dev/null +++ b/mockito/output/07-captor-empty.txt @@ -0,0 +1,11 @@ +org.mockito.exceptions.base.MockitoException: +No argument value was captured! +You might have forgotten to use argument.capture() in verify()... +...or you used capture() in stubbing but stubbed method was not called. +Be aware that it is recommended to use capture() only with verify() + +Examples of correct argument capturing: + ArgumentCaptor argument = ArgumentCaptor.forClass(Person.class); + verify(mock).doSomething(argument.capture()); + assertEquals("John", argument.getValue().getName()); + diff --git a/mockito/output/08-bdd-style.txt b/mockito/output/08-bdd-style.txt new file mode 100644 index 0000000..d3163a4 --- /dev/null +++ b/mockito/output/08-bdd-style.txt @@ -0,0 +1,5 @@ +receipt approved : true +then(gateway).should() : charge("acc-1", 900) ok +then(audit).should() : record("CHECKOUT", "acc-1:900") ok +shouldHaveNoMoreInteractions on audit : ok +InOrder gateway.charge -> audit.record : ok diff --git a/mockito/output/09-bdd-throw-and-never.txt b/mockito/output/09-bdd-throw-and-never.txt new file mode 100644 index 0000000..0f32461 --- /dev/null +++ b/mockito/output/09-bdd-throw-and-never.txt @@ -0,0 +1,3 @@ +exception : gateway unavailable +gateway.charge never called : ok +audit recorded CHECKOUT_REJECTED : ok diff --git a/mockito/output/10-unnecessary-stubbing.txt b/mockito/output/10-unnecessary-stubbing.txt new file mode 100644 index 0000000..b77c724 --- /dev/null +++ b/mockito/output/10-unnecessary-stubbing.txt @@ -0,0 +1,6 @@ +UnnecessaryStubbingException: + Unnecessary stubbings detected. + Clean & maintainable test code requires zero unnecessary code. + Following stubbings are unnecessary (click to navigate to relevant line of code): + 1. -> at lab.mockito.StrictnessTest.unusedStubIsReportedWhenTheSessionEnds(StrictnessTest.java:33) + Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class. diff --git a/mockito/output/11-potential-stubbing-problem.txt b/mockito/output/11-potential-stubbing-problem.txt new file mode 100644 index 0000000..ef5d2c0 --- /dev/null +++ b/mockito/output/11-potential-stubbing-problem.txt @@ -0,0 +1,16 @@ +PotentialStubbingProblem: + Strict stubbing argument mismatch. Please check: + - this invocation of 'charge' method: + paymentGateway.charge("acc-1", 1000L); + -> at lab.mockito.CheckoutService.checkout(CheckoutService.java:20) + - has following stubbing(s) with different arguments: + 1. paymentGateway.charge("acc-1", 900L); stubbed with: [Returns: Receipt[id=r, account=acc-1, cents=900, approved=true]] + -> at lab.mockito.StrictnessTest.sameMethodDifferentArgumentsIsAPotentialStubbingProblem(StrictnessTest.java:48) + Typically, stubbing argument mismatch indicates user mistake when writing tests. + Mockito fails early so that you can debug potential problem easily. + However, there are legit scenarios when this exception generates false negative signal: + - stubbing the same method multiple times using 'given().will()' or 'when().then()' API + Please use 'will().given()' or 'doReturn().when()' API for stubbing. + - stubbed method is intentionally invoked with different arguments by code under test + Please use default or 'silent' JUnit Rule (equivalent of Strictness.LENIENT). + For more information see javadoc for PotentialStubbingProblem class. diff --git a/mockito/output/11b-same-file-mismatch-not-flagged.txt b/mockito/output/11b-same-file-mismatch-not-flagged.txt new file mode 100644 index 0000000..3afd869 --- /dev/null +++ b/mockito/output/11b-same-file-mismatch-not-flagged.txt @@ -0,0 +1 @@ +charge("acc-1", 1000) from the test file returns : null (no PotentialStubbingProblem) diff --git a/mockito/output/12-lenient.txt b/mockito/output/12-lenient.txt new file mode 100644 index 0000000..e6dee59 --- /dev/null +++ b/mockito/output/12-lenient.txt @@ -0,0 +1,2 @@ +lenient stub, mismatching call returns : null +finishMocking with unused lenient stubs : no exception diff --git a/mockito/output/13-no-session-no-checks.txt b/mockito/output/13-no-session-no-checks.txt new file mode 100644 index 0000000..c80b436 --- /dev/null +++ b/mockito/output/13-no-session-no-checks.txt @@ -0,0 +1 @@ +mismatching call on a plain mock returns : null (no strictness error) diff --git a/mockito/output/14-dynamic-agent-warning.txt b/mockito/output/14-dynamic-agent-warning.txt new file mode 100644 index 0000000..5aa643a --- /dev/null +++ b/mockito/output/14-dynamic-agent-warning.txt @@ -0,0 +1,5 @@ +Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 +WARNING: A Java agent has been loaded dynamically (/byte-buddy-agent-1.17.7.jar) +WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning +WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information +WARNING: Dynamic loading of agents will be disallowed by default in a future release diff --git a/mockito/pom.xml b/mockito/pom.xml new file mode 100644 index 0000000..2560cdd --- /dev/null +++ b/mockito/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + com.ankurm.testing + mockito-lab + 1.0.0 + + + 25 + UTF-8 + 6.1.3 + 5.24.0 + + ${settings.localRepository}/org/mockito/mockito-core/${mockito.version}/mockito-core-${mockito.version}.jar + -javaagent:${mockito.agent} -Xshare:off + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + ${surefire.argLine} + + + + + + + + + no-agent + + -Xshare:off + + + + diff --git a/mockito/scripts/run-all.sh b/mockito/scripts/run-all.sh new file mode 100755 index 0000000..8e20c18 --- /dev/null +++ b/mockito/scripts/run-all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Regenerates every file in mockito/output/. +set -euo pipefail +cd "$(dirname "$0")/.." +unset JAVA_TOOL_OPTIONS + +# 1. The whole suite with Mockito loaded as a -javaagent (the recommended setup). +mvn -B -q clean test + +# 2. The same single test WITHOUT the agent, to capture what a JDK 21+ prints when Mockito self-attaches. +mvn -B -Pno-agent test -Dtest=MockMakerTest 2>&1 \ + | grep -E '^(Mockito is currently|WARNING: (A Java agent|If a serviceability|Dynamic loading))' \ + | sed -E 's#\(/[^)]*/byte-buddy-agent-([0-9.]+)\.jar\)#(/byte-buddy-agent-\1.jar)#' \ + > output/14-dynamic-agent-warning.txt +echo "done: $(ls output | wc -l) files in output/" diff --git a/mockito/src/main/java/lab/mockito/AuditLog.java b/mockito/src/main/java/lab/mockito/AuditLog.java new file mode 100644 index 0000000..b7703cc --- /dev/null +++ b/mockito/src/main/java/lab/mockito/AuditLog.java @@ -0,0 +1,5 @@ +package lab.mockito; + +public interface AuditLog { + void record(String event, String detail); +} diff --git a/mockito/src/main/java/lab/mockito/CheckoutService.java b/mockito/src/main/java/lab/mockito/CheckoutService.java new file mode 100644 index 0000000..6c7937f --- /dev/null +++ b/mockito/src/main/java/lab/mockito/CheckoutService.java @@ -0,0 +1,24 @@ +package lab.mockito; + +public class CheckoutService { + private final PaymentGateway gateway; + private final AuditLog audit; + private final PriceCalculator calculator; + + public CheckoutService(PaymentGateway gateway, AuditLog audit, PriceCalculator calculator) { + this.gateway = gateway; + this.audit = audit; + this.calculator = calculator; + } + + public Receipt checkout(String account, long cents, int discountPercent) { + if (!gateway.isAvailable()) { + audit.record("CHECKOUT_REJECTED", account); + throw new IllegalStateException("gateway unavailable"); + } + long payable = calculator.applyDiscount(cents, discountPercent); + Receipt receipt = gateway.charge(account, payable); + audit.record("CHECKOUT", account + ":" + payable); + return receipt; + } +} diff --git a/mockito/src/main/java/lab/mockito/EmailClient.java b/mockito/src/main/java/lab/mockito/EmailClient.java new file mode 100644 index 0000000..91b77b2 --- /dev/null +++ b/mockito/src/main/java/lab/mockito/EmailClient.java @@ -0,0 +1,16 @@ +package lab.mockito; + +/** Pretend this talks to an SMTP server. Real sending is not what the tests are about. */ +public class EmailClient { + public EmailClient() { + throw new IllegalStateException("real SMTP connection attempted"); + } + + public EmailClient(String host) { + throw new IllegalStateException("real SMTP connection attempted to " + host); + } + + public boolean send(String to, String body) { + throw new IllegalStateException("real email sent to " + to); + } +} diff --git a/mockito/src/main/java/lab/mockito/Ids.java b/mockito/src/main/java/lab/mockito/Ids.java new file mode 100644 index 0000000..35fdf26 --- /dev/null +++ b/mockito/src/main/java/lab/mockito/Ids.java @@ -0,0 +1,16 @@ +package lab.mockito; + +import java.util.UUID; + +/** Static helpers: hard to substitute without mockStatic. */ +public final class Ids { + private Ids() {} + + public static String newOrderId() { + return "ORD-" + UUID.randomUUID().toString().substring(0, 8); + } + + public static long nowMillis() { + return System.currentTimeMillis(); + } +} diff --git a/mockito/src/main/java/lab/mockito/NotificationService.java b/mockito/src/main/java/lab/mockito/NotificationService.java new file mode 100644 index 0000000..74f19d0 --- /dev/null +++ b/mockito/src/main/java/lab/mockito/NotificationService.java @@ -0,0 +1,9 @@ +package lab.mockito; + +/** Builds its own EmailClient with `new` - no injection point, so only mockConstruction can intercept it. */ +public class NotificationService { + public boolean welcome(String user) { + EmailClient client = new EmailClient("smtp.example.com"); + return client.send(user + "@example.com", "Welcome, " + user); + } +} diff --git a/mockito/src/main/java/lab/mockito/OrderService.java b/mockito/src/main/java/lab/mockito/OrderService.java new file mode 100644 index 0000000..6fb61bc --- /dev/null +++ b/mockito/src/main/java/lab/mockito/OrderService.java @@ -0,0 +1,8 @@ +package lab.mockito; + +public class OrderService { + public String place(String customer) { + String id = Ids.newOrderId(); + return id + "@" + customer; + } +} diff --git a/mockito/src/main/java/lab/mockito/PaymentGateway.java b/mockito/src/main/java/lab/mockito/PaymentGateway.java new file mode 100644 index 0000000..93bc4dd --- /dev/null +++ b/mockito/src/main/java/lab/mockito/PaymentGateway.java @@ -0,0 +1,7 @@ +package lab.mockito; + +public interface PaymentGateway { + Receipt charge(String account, long cents); + + boolean isAvailable(); +} diff --git a/mockito/src/main/java/lab/mockito/PriceCalculator.java b/mockito/src/main/java/lab/mockito/PriceCalculator.java new file mode 100644 index 0000000..429699d --- /dev/null +++ b/mockito/src/main/java/lab/mockito/PriceCalculator.java @@ -0,0 +1,8 @@ +package lab.mockito; + +/** A final class with a final method: the classic "Mockito 1/2 could not mock this" case. */ +public final class PriceCalculator { + public final long applyDiscount(long cents, int percent) { + return cents - (cents * percent / 100); + } +} diff --git a/mockito/src/main/java/lab/mockito/Receipt.java b/mockito/src/main/java/lab/mockito/Receipt.java new file mode 100644 index 0000000..29d6523 --- /dev/null +++ b/mockito/src/main/java/lab/mockito/Receipt.java @@ -0,0 +1,3 @@ +package lab.mockito; + +public record Receipt(String id, String account, long cents, boolean approved) {} diff --git a/mockito/src/test/java/lab/mockito/BddStyleTest.java b/mockito/src/test/java/lab/mockito/BddStyleTest.java new file mode 100644 index 0000000..14a5427 --- /dev/null +++ b/mockito/src/test/java/lab/mockito/BddStyleTest.java @@ -0,0 +1,62 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BddStyleTest { + + @Mock PaymentGateway gateway; + @Mock AuditLog audit; + @Mock PriceCalculator calculator; + + @Test + void givenWhenThen() { + Transcript t = new Transcript("08-bdd-style.txt"); + // given + given(gateway.isAvailable()).willReturn(true); + given(calculator.applyDiscount(1000, 10)).willReturn(900L); + given(gateway.charge("acc-1", 900)).willReturn(new Receipt("r-1", "acc-1", 900, true)); + CheckoutService svc = new CheckoutService(gateway, audit, calculator); + + // when + Receipt receipt = svc.checkout("acc-1", 1000, 10); + + // then + assertTrue(receipt.approved()); + then(gateway).should().charge("acc-1", 900); + then(audit).should().record("CHECKOUT", "acc-1:900"); + then(audit).shouldHaveNoMoreInteractions(); + InOrder order = Mockito.inOrder(gateway, audit); + order.verify(gateway).charge("acc-1", 900); + order.verify(audit).record("CHECKOUT", "acc-1:900"); + t.line("receipt approved : %s", receipt.approved()); + t.line("then(gateway).should() : charge(\"acc-1\", 900) ok"); + t.line("then(audit).should() : record(\"CHECKOUT\", \"acc-1:900\") ok"); + t.line("shouldHaveNoMoreInteractions on audit : ok"); + t.line("InOrder gateway.charge -> audit.record : ok"); + t.write(); + } + + @Test + void willThrowAndNeverCalled() { + Transcript t = new Transcript("09-bdd-throw-and-never.txt"); + given(gateway.isAvailable()).willReturn(false); + CheckoutService svc = new CheckoutService(gateway, audit, calculator); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> svc.checkout("acc-2", 1000, 10)); + then(gateway).should(never()).charge(anyString(), anyLong()); + then(audit).should().record("CHECKOUT_REJECTED", "acc-2"); + t.line("exception : %s", e.getMessage()); + t.line("gateway.charge never called : ok"); + t.line("audit recorded CHECKOUT_REJECTED : ok"); + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/CaptorTest.java b/mockito/src/test/java/lab/mockito/CaptorTest.java new file mode 100644 index 0000000..47232e0 --- /dev/null +++ b/mockito/src/test/java/lab/mockito/CaptorTest.java @@ -0,0 +1,51 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class CaptorTest { + + @Mock PaymentGateway gateway; + @Mock AuditLog audit; + @Captor ArgumentCaptor detail; + @Captor ArgumentCaptor cents; + + @Test + void captureWhatTheCodeActuallySent() { + Transcript t = new Transcript("06-argument-captor.txt"); + when(gateway.isAvailable()).thenReturn(true); + when(gateway.charge(anyString(), anyLong())).thenReturn(new Receipt("r-1", "acc-9", 900, true)); + CheckoutService svc = new CheckoutService(gateway, audit, new PriceCalculator()); + + svc.checkout("acc-9", 1000, 10); + svc.checkout("acc-7", 500, 0); + + verify(gateway, times(2)).charge(anyString(), cents.capture()); + t.line("charged amounts : %s", cents.getAllValues()); + assertEquals(List.of(900L, 500L), cents.getAllValues()); + t.line("last captured : %d", cents.getValue()); + + verify(audit, times(2)).record(eq("CHECKOUT"), detail.capture()); + t.line("audit details : %s", detail.getAllValues()); + assertEquals(List.of("acc-9:900", "acc-7:500"), detail.getAllValues()); + t.write(); + } + + @Test + void getValueBeforeAnyCaptureFails() { + Transcript t = new Transcript("07-captor-empty.txt"); + ArgumentCaptor empty = ArgumentCaptor.forClass(String.class); + Exception e = assertThrows(Exception.class, empty::getValue); + t.line("%s: %s", e.getClass().getName(), e.getMessage()); + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/ConstructionMockingTest.java b/mockito/src/test/java/lab/mockito/ConstructionMockingTest.java new file mode 100644 index 0000000..fa47900 --- /dev/null +++ b/mockito/src/test/java/lab/mockito/ConstructionMockingTest.java @@ -0,0 +1,34 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; + +class ConstructionMockingTest { + + @Test + void mockConstructionInterceptsNewInsideTheClassUnderTest() { + Transcript t = new Transcript("05-mock-construction.txt"); + // Without mockConstruction the real constructor throws - that is the point of the demo. + IllegalStateException real = assertThrows(IllegalStateException.class, () -> new NotificationService().welcome("bob")); + t.line("no interception : %s", real.getMessage()); + + try (MockedConstruction constructed = mockConstruction(EmailClient.class, + (mock, context) -> { + t.line("constructor args : %s", context.arguments()); + t.line("construction # : %d", context.getCount()); + when(mock.send(anyString(), anyString())).thenReturn(true); + })) { + boolean sent = new NotificationService().welcome("bob"); + t.line("welcome(\"bob\") : %s", sent); + t.line("instances built : %d", constructed.constructed().size()); + assertTrue(sent); + assertEquals(1, constructed.constructed().size()); + verify(constructed.constructed().get(0)).send("bob@example.com", "Welcome, bob"); + t.line("verified : send(\"bob@example.com\", \"Welcome, bob\")"); + } + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/MockMakerTest.java b/mockito/src/test/java/lab/mockito/MockMakerTest.java new file mode 100644 index 0000000..3a5d4ec --- /dev/null +++ b/mockito/src/test/java/lab/mockito/MockMakerTest.java @@ -0,0 +1,34 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.plugins.MockMaker; + +class MockMakerTest { + + @Test + void inlineMockMakerIsTheDefaultAndMocksFinalTypes() { + Transcript t = new Transcript("01-mock-maker.txt"); + MockMaker maker = Mockito.framework().getPlugins().getDefaultPlugin(MockMaker.class); + t.line("default MockMaker : %s", maker.getClass().getName()); + assertTrue(maker.getClass().getSimpleName().contains("Inline")); + + PriceCalculator calc = mock(PriceCalculator.class); + t.line("PriceCalculator is final : %s", java.lang.reflect.Modifier.isFinal(PriceCalculator.class.getModifiers())); + t.line("mock class name : %s", calc.getClass().getName()); + t.line("mock class == real class : %s", calc.getClass() == PriceCalculator.class); + + when(calc.applyDiscount(1000, 10)).thenReturn(42L); + t.line("stubbed final method : applyDiscount(1000, 10) = %d", calc.applyDiscount(1000, 10)); + t.line("unstubbed final method : applyDiscount(1000, 20) = %d", calc.applyDiscount(1000, 20)); + assertEquals(42L, calc.applyDiscount(1000, 10)); + assertEquals(0L, calc.applyDiscount(1000, 20)); + + Receipt r = mock(Receipt.class); + t.line("mocked a record : %s", r.getClass() == Receipt.class); + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/StaticMockingTest.java b/mockito/src/test/java/lab/mockito/StaticMockingTest.java new file mode 100644 index 0000000..4eac14f --- /dev/null +++ b/mockito/src/test/java/lab/mockito/StaticMockingTest.java @@ -0,0 +1,83 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.exceptions.base.MockitoException; + +class StaticMockingTest { + + @Test + void mockStaticReplacesTheMethodOnlyInsideTheTryBlock() { + Transcript t = new Transcript("02-mock-static.txt"); + String before = Ids.newOrderId(); + t.line("before scope : id starts with ORD- : %s", before.startsWith("ORD-")); + + try (MockedStatic ids = mockStatic(Ids.class)) { + ids.when(Ids::newOrderId).thenReturn("ORD-FIXED"); + String placed = new OrderService().place("alice"); + t.line("inside scope : place(\"alice\") = %s", placed); + assertEquals("ORD-FIXED@alice", placed); + + ids.verify(Ids::newOrderId); + ids.verify(Ids::newOrderId, times(1)); + t.line("inside scope : unstubbed Ids.nowMillis() = %d (default answer, not the clock)", Ids.nowMillis()); + assertEquals(0L, Ids.nowMillis()); + } + + String after = Ids.newOrderId(); + t.line("after scope : id starts with ORD- and is not FIXED : %s", after.startsWith("ORD-") && !after.equals("ORD-FIXED")); + assertNotEquals("ORD-FIXED", after); + t.write(); + } + + @Test + void staticMockIsThreadLocal() throws Exception { + Transcript t = new Transcript("03-static-thread-local.txt"); + AtomicReference seenByOtherThread = new AtomicReference<>(); + try (MockedStatic ids = mockStatic(Ids.class)) { + ids.when(Ids::newOrderId).thenReturn("ORD-FIXED"); + Thread other = new Thread(() -> seenByOtherThread.set(Ids.newOrderId())); + other.start(); + other.join(); + t.line("test thread sees : %s", Ids.newOrderId()); + t.line("other thread sees : real id, not FIXED = %s", !"ORD-FIXED".equals(seenByOtherThread.get())); + } + assertNotEquals("ORD-FIXED", seenByOtherThread.get()); + t.write(); + } + + @Test + void registeringTheSameStaticTwiceOnOneThreadFails() { + Transcript t = new Transcript("04-static-already-registered.txt"); + try (MockedStatic first = mockStatic(Ids.class)) { + MockitoException e = assertThrows(MockitoException.class, () -> mockStatic(Ids.class)); + t.block("MockitoException:", e.getMessage()); + } + t.write(); + } + + @Test + void callsRealMethodsKeepsTheRestOfTheClassReal() { + Transcript t = new Transcript("04b-static-calls-real-methods.txt"); + try (MockedStatic ids = mockStatic(Ids.class, CALLS_REAL_METHODS)) { + ids.when(Ids::newOrderId).thenReturn("ORD-FIXED"); + long now = Ids.nowMillis(); + t.line("newOrderId() = %s", Ids.newOrderId()); + t.line("nowMillis() is the real clock (> 0) : %s", now > 0); + assertTrue(now > 0); + } + t.write(); + } + + @Test + void coreJdkClassesCannotBeStaticallyMocked() { + Transcript t = new Transcript("04c-static-mock-system.txt"); + MockitoException e = assertThrows(MockitoException.class, () -> mockStatic(System.class)); + t.block("MockitoException:", e.getMessage()); + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/StrictnessTest.java b/mockito/src/test/java/lab/mockito/StrictnessTest.java new file mode 100644 index 0000000..f908218 --- /dev/null +++ b/mockito/src/test/java/lab/mockito/StrictnessTest.java @@ -0,0 +1,103 @@ +package lab.mockito; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.mockito.MockitoSession; +import org.mockito.exceptions.misusing.PotentialStubbingProblem; +import org.mockito.exceptions.misusing.UnnecessaryStubbingException; +import org.mockito.quality.Strictness; + +/** Uses the MockitoSession API so the strictness failures can be caught and recorded. */ +class StrictnessTest { + + private static void finish(MockitoSession session) { + try { + session.finishMocking(); + } catch (UnnecessaryStubbingException ignored) { + // the demo stubs are deliberately left unused in some tests + } + } + + private MockitoSession strict() { + return org.mockito.Mockito.mockitoSession().strictness(Strictness.STRICT_STUBS).startMocking(); + } + + @Test + void unusedStubIsReportedWhenTheSessionEnds() { + Transcript t = new Transcript("10-unnecessary-stubbing.txt"); + MockitoSession session = strict(); + PaymentGateway gateway = mock(PaymentGateway.class); + when(gateway.isAvailable()).thenReturn(true); // used below + when(gateway.charge("acc-1", 100)).thenReturn(null); // never used + gateway.isAvailable(); + + UnnecessaryStubbingException e = assertThrows(UnnecessaryStubbingException.class, session::finishMocking); + t.block("UnnecessaryStubbingException:", e.getMessage()); + t.write(); + } + + @Test + void sameMethodDifferentArgumentsIsAPotentialStubbingProblem() { + Transcript t = new Transcript("11-potential-stubbing-problem.txt"); + MockitoSession session = strict(); + try { + PaymentGateway gateway = mock(PaymentGateway.class); + when(gateway.isAvailable()).thenReturn(true); + when(gateway.charge("acc-1", 900)).thenReturn(new Receipt("r", "acc-1", 900, true)); + CheckoutService svc = new CheckoutService(gateway, mock(AuditLog.class), new PriceCalculator()); + + // 1000 cents with 0% discount reaches charge("acc-1", 1000) inside CheckoutService.java, + // a different source file from this test: Mockito flags the argument mismatch. + PotentialStubbingProblem e = assertThrows(PotentialStubbingProblem.class, () -> svc.checkout("acc-1", 1000, 0)); + t.block("PotentialStubbingProblem:", e.getMessage()); + } finally { + finish(session); + } + t.write(); + } + + @Test + void sameFileMismatchIsNotFlagged() { + Transcript t = new Transcript("11b-same-file-mismatch-not-flagged.txt"); + MockitoSession session = strict(); + try { + PaymentGateway gateway = mock(PaymentGateway.class); + when(gateway.charge("acc-1", 900)).thenReturn(new Receipt("r", "acc-1", 900, true)); + // Same call-shape as the test above, but made from this very file. + Receipt r = assertDoesNotThrow(() -> gateway.charge("acc-1", 1000)); + t.line("charge(\"acc-1\", 1000) from the test file returns : %s (no PotentialStubbingProblem)", r); + assertNull(r); + } finally { + finish(session); + } + t.write(); + } + + @Test + void lenientStubsAreExemptAndStrictnessCanBeLoosenedPerStub() { + Transcript t = new Transcript("12-lenient.txt"); + MockitoSession session = strict(); + PaymentGateway gateway = mock(PaymentGateway.class); + lenient().when(gateway.charge("acc-1", 100)).thenReturn(new Receipt("r", "acc-1", 100, true)); + lenient().when(gateway.isAvailable()).thenReturn(true); + Receipt other = gateway.charge("acc-1", 999); // arg mismatch, but the stub is lenient + t.line("lenient stub, mismatching call returns : %s", other); + assertNull(other); + session.finishMocking(); // unused lenient stub: no exception + t.line("finishMocking with unused lenient stubs : no exception"); + t.write(); + } + + @Test + void plainMockWithoutSessionIsLenient() { + Transcript t = new Transcript("13-no-session-no-checks.txt"); + PaymentGateway gateway = mock(PaymentGateway.class); + when(gateway.charge("acc-1", 100)).thenReturn(new Receipt("r", "acc-1", 100, true)); + Receipt other = gateway.charge("acc-1", 999); + t.line("mismatching call on a plain mock returns : %s (no strictness error)", other); + assertNull(other); + t.write(); + } +} diff --git a/mockito/src/test/java/lab/mockito/Transcript.java b/mockito/src/test/java/lab/mockito/Transcript.java new file mode 100644 index 0000000..02ddb81 --- /dev/null +++ b/mockito/src/test/java/lab/mockito/Transcript.java @@ -0,0 +1,45 @@ +package lab.mockito; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Collects lines during a test and writes them to output/NN-name.txt. + * The same test asserts the same facts, so every figure in the blog post is an assertion. + */ +final class Transcript { + private final String file; + private final List lines = new ArrayList<>(); + + Transcript(String file) { + this.file = file; + } + + Transcript line(String format, Object... args) { + lines.add(args.length == 0 ? format : String.format(format, args)); + return this; + } + + /** Message text with the volatile first-line class prefix kept, trailing whitespace trimmed. */ + Transcript block(String label, String text) { + lines.add(label); + for (String l : text.strip().split("\n")) { + lines.add(" " + l.stripTrailing()); + } + return this; + } + + void write() { + try { + Path dir = Path.of("output"); + Files.createDirectories(dir); + Files.writeString(dir.resolve(file), String.join("\n", lines) + "\n"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +}