MUnit · mocking · coverage gates · CI/CD. Write unit tests that catch regressions before they reach production, enforce coverage thresholds in your pipeline, and ship Mule code with confidence.
In Chapter 3 we built a production-grade flow with full error handling, Scatter-Gather, and a reusable error envelope. Now we ensure it keeps working as we change it. This chapter is about automated MUnit testing — with real state diagrams, complete test code, and coverage gates that block broken deploys.
The companion repo now ships with customer-api-v2-test-suite.xml — a full MUnit suite covering every terminal state of the v2 flow, plus a Customer 360 Scatter-Gather test. The pom.xml is wired with the MUnit Maven plugin and coverage gates.
By the end of this chapter you will be able to:
- Understand the MUnit test execution lifecycle
- Write unit tests with and without mocking — including advanced scenarios
- Mock Database, HTTP, and typed Mule errors with full control
- Test Scatter-Gather flows by mocking all routes simultaneously
- Use spy verification (
verify-call) to assert connector call counts - Enforce coverage thresholds in CI/CD via the
munit-maven-plugin - Diagnose the five most common MUnit errors in under a minute
01 · Why TestThe Cost of Skipping Tests
Every Mule shop hits the same crossroads: ship fast without tests, or move slower with safety nets. The first path is seductive — until the first 3am production fire caused by a “harmless” refactor.
The fast lane to regret
Fear of touching anything that “works”. Manual regression marathons. Silent data corruption across systems.
- Breaking changes hit prod undetected
- N×(N-1) manual scenarios per release
- Refactoring becomes terrifying
- On-call burnout from preventable issues
The path you’ll thank yourself for
Refactor safely. CI blocks broken deploys. Test names document intent better than comments.
- Regressions caught in seconds
mvn testruns in <60s per PR- 80%+ flow coverage enforced
- Confidence to ship on Fridays
MUnit is MuleSoft’s dedicated testing framework for Mule 4 applications — fully integrated with Anypoint Studio, Anypoint Code Builder, and Maven/Surefire for CI/CD. Tests are Mule XML files in src/test/munit that use the munit: and munit-tools: namespaces.
MUnit 3.x targets Mule runtime 4.4+. For Mule 4.1–4.3, use MUnit 2.x. Mismatched versions cause obscure classloader errors at boot — always pin both in your pom.xml via a ${munit.version} property.
02 · SetupWiring MUnit Into Your Project
Three additions to pom.xml: a version property, the test-scoped MUnit dependencies, and the Maven plugin with coverage configuration. Define ${munit.version} once — never hardcode it in multiple places.
<properties> <app.runtime>4.6.0</app.runtime> <munit.version>3.2.0</munit.version> </properties> <dependencies> <!-- Test-scope: only on classpath during mvn test --> <dependency> <groupId>com.mulesoft.munit</groupId> <artifactId>munit-runner</artifactId> <version>${munit.version}</version> <classifier>mule-plugin</classifier> <scope>test</scope> </dependency> <dependency> <groupId>com.mulesoft.munit</groupId> <artifactId>munit-tools</artifactId> <version>${munit.version}</version> <classifier>mule-plugin</classifier> <scope>test</scope> </dependency> </dependencies>
03 · AnatomyHow an MUnit Test Runs
Every MUnit test follows the same three-phase lifecycle: Arrange → Execute → Assert. Understanding the state machine underneath helps you write tests that are robust, predictable, and easy to debug.
04 · MockingHow mock-when Intercepts Connectors
Real database calls in tests are slow, fragile, and brittle. They need a running DB, seed data, network access, and they can’t easily simulate connectivity loss. The solution: mock-when wraps the real connector with a proxy at runtime. Your flow XML is unchanged — only the processor’s implementation is swapped.
05 · The MapFlow Under Test — State Diagram
Before writing tests, draw the state diagram of every flow. Each terminal state becomes exactly one test. Missing a terminal state means an untested production path — and that’s where bugs hide.
Here’s the GET /customers/{id}/v2 flow we built in Chapter 3. It has 5 internal states but 4 terminal (response) states:
06 · Test SuiteAll 4 Terminal States — Complete Code
6.1 Test 1 — 200 OK (happy path)
<munit:test name="get-customer-v2-returns-200-when-found"> <munit:behavior> <munit:set-event> <munit:attributes value='#[{ uriParams: { id: "1" }, method: "GET", headers: {} }]'/> </munit:set-event> <munit-tools:mock-when processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Find customer by id" attributeName="doc:name"/> </munit-tools:with-attributes> <munit-tools:then-return> <munit-tools:payload value='#[[{ id: 1, name: "Alice Dupont", email: "alice@example.com", city: "Paris" }]]'/> </munit-tools:then-return> </munit-tools:mock-when> </munit:behavior> <munit:execution> <flow-ref name="get-customer-v2-flow"/> </munit:execution> <munit:validation> <munit-tools:assert-that expression="#[payload.name]" is='#[MunitTools::equalTo("Alice Dupont")]'/> </munit:validation> </munit:test>
The example matches by doc:name="Find customer by id". This is convenient but fragile — if a teammate renames the component in Studio, the mock silently stops matching and the real DB is hit. For long-lived suites, prefer matching by config-ref (rarely renamed) or combine attributes for redundancy.
6.2 Test 2 — 404 Not Found
<munit:test name="get-customer-v2-returns-404-when-missing"> <munit:behavior> <munit:set-event> <munit:attributes value='#[{ uriParams: { id: "999" }, method: "GET", headers: {} }]'/> </munit:set-event> <munit-tools:mock-when processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Find customer by id" attributeName="doc:name"/> </munit-tools:with-attributes> <munit-tools:then-return> <munit-tools:payload value="#[[]]"/> </munit-tools:then-return> </munit-tools:mock-when> </munit:behavior> <munit:execution> <flow-ref name="get-customer-v2-flow"/> </munit:execution> <munit:validation> <munit-tools:assert-that expression="#[vars.httpStatus]" is="#[MunitTools::equalTo(404)]"/> <munit-tools:assert-that expression="#[payload.messages[0].errorCode]" is='#[MunitTools::equalTo("CUSTOMER_NOT_FOUND")]'/> </munit:validation> </munit:test>
6.3 Test 3 — 400 Bad Request (spy: DB never reached)
This is the most powerful pattern in MUnit. verify-call with times="0" asserts the connector was never called — the short-circuit on invalid input. Without this, you’d never know if the validation actually bails out before the DB.
<munit:test name="get-customer-v2-returns-400-for-invalid-id"> <munit:behavior> <munit:set-event> <munit:attributes value='#[{ uriParams: { id: "abc" }, method: "GET", headers: {} }]'/> </munit:set-event> <!-- Spy on the DB call so we can verify call count --> <munit-tools:spy processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Find customer by id" attributeName="doc:name"/> </munit-tools:with-attributes> </munit-tools:spy> </munit:behavior> <munit:execution> <flow-ref name="get-customer-v2-flow"/> </munit:execution> <munit:validation> <munit-tools:assert-that expression="#[vars.httpStatus]" is="#[MunitTools::equalTo(400)]"/> <munit-tools:assert-that expression="#[payload.messages[0].errorCode]" is='#[MunitTools::equalTo("INVALID_ID")]'/> <!-- CRITICAL · DB must never be reached for malformed input --> <munit-tools:verify-call processor="db:select" times="0"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Find customer by id" attributeName="doc:name"/> </munit-tools:with-attributes> </munit-tools:verify-call> </munit:validation> </munit:test>
6.4 Test 4 — 503 DB Connectivity Error
Throwing typed Mule errors from a mock lets you simulate failures you’d otherwise need to take down infrastructure to reproduce. typeId="DB:CONNECTIVITY" matches the typed handler in our v2 flow.
<munit-tools:mock-when processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Find customer by id" attributeName="doc:name"/> </munit-tools:with-attributes> <!-- Throw a typed Mule error instead of returning a payload --> <munit-tools:error typeId="DB:CONNECTIVITY" description="Simulated DB connectivity loss"/> </munit-tools:mock-when> <!-- After execution: assert the try scope caught it and returned 503 --> <munit-tools:assert-that expression="#[vars.httpStatus]" is="#[MunitTools::equalTo(503)]"/> <munit-tools:assert-that expression="#[payload.messages[0].errorCode]" is='#[MunitTools::equalTo("DB_UNAVAILABLE")]'/>
typeId
The value must match a registered error type — either provided by the connector (DB:CONNECTIVITY, HTTP:CONNECTIVITY, HTTP:TIMEOUT) or declared in your app via error-mapping. Typos compile silently but never match your on-error-continue handler. Always test the unhappy path before trusting the assertion.
6.5 Bonus — Testing Scatter-Gather with 3 Route Mocks
Scatter-Gather routes are mocked independently — each by its own doc:name. This is why descriptive doc names matter: they’re your test-time selectors.
<!-- Route 0 — customer profile --> <munit-tools:mock-when processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Get customer profile" attributeName="doc:name"/> </munit-tools:with-attributes> <munit-tools:then-return> <munit-tools:payload value='#[[{ id: 1, name: "Alice", email: "a@x.com", city: "Paris" }]]'/> </munit-tools:then-return> </munit-tools:mock-when> <!-- Route 1 — order count --> <munit-tools:mock-when processor="db:select"> <munit-tools:with-attributes> <munit-tools:with-attribute whereValue="Count orders for customer" attributeName="doc:name"/> </munit-tools:with-attributes> <munit-tools:then-return> <munit-tools:payload value='#[[{ order_count: 7 }]]'/> </munit-tools:then-return> </munit-tools:mock-when> <!-- Validation: merged aggregate response --> <munit-tools:assert-that expression="#[payload.customer.name]" is='#[MunitTools::equalTo("Alice")]'/> <munit-tools:assert-that expression="#[payload.stats.orderCount]" is="#[MunitTools::equalTo(7)]"/>
07 · PyramidThe Testing Pyramid
Not all tests are created equal. The pyramid governs which type to write for which behaviour — and how many of each.
08 · CoverageThe CI/CD Gate
Coverage tracks which event processors (components) in each flow were actually executed by your tests. Measured at three levels: application (overall), resource (per XML file), and flow (per flow).
8.1 The Maven Plugin — Coverage Gate Configuration
munit-extensions-maven-plugin is for testing connector/extension modules. For testing Mule applications use munit-maven-plugin (com.mulesoft.munit.tools). Coverage also requires the coverage-report goal and a <formats> block.
<plugin> <groupId>com.mulesoft.munit.tools</groupId> <artifactId>munit-maven-plugin</artifactId> <version>${munit.version}</version> <executions> <execution> <id>test</id> <phase>test</phase> <goals> <goal>test</goal> <!-- coverage-report goal is REQUIRED for coverage to be aggregated --> <goal>coverage-report</goal> </goals> </execution> </executions> <configuration> <coverage> <runCoverage>true</runCoverage> <failBuild>true</failBuild> <!-- Pragmatic starting thresholds — ratchet up over time --> <requiredApplicationCoverage>80</requiredApplicationCoverage> <requiredResourceCoverage>70</requiredResourceCoverage> <requiredFlowCoverage>60</requiredFlowCoverage> <formats> <format>console</format> <format>html</format> <format>json</format> </formats> </coverage> </configuration> </plugin>
8.2 Running the Suite
# Run all MUnit tests + generate coverage report mvn clean test # Run a single test suite by file name mvn clean test -Dmunit.test=customer-api-v2-test-suite.xml # Run tests tagged with a specific label mvn clean test -Dmunit.tags=smoke # Skip tests for an emergency hotfix deploy (use sparingly!) mvn deploy -DskipMunitTests=true # HTML coverage report at: # target/site/munit/coverage/summary.html # JSON coverage report at: # target/site/munit/coverage/munit-coverage.json
09 · TroubleshootingThe Five Errors You Will See
Most failing MUnit suites trip on the same five issues. Here’s how to diagnose each in under a minute:
| Symptom | Likely cause & fix |
|---|---|
Mock was not invoked / real DB hit |
Mismatched doc:name. Open Studio, copy the exact doc:name from the component into whereValue. Verify with a verify-call assertion. For long-lived suites, prefer matching by config-ref. |
MuleRuntimeException: Cannot find flow |
Flow lives in a config file not loaded by the test. Move the test to src/test/munit mirroring the package, or add explicit <munit:config name="…">. |
NullPayloadException in assertion |
Missing set-event or wrong mediaType. Always declare both value and mediaType="application/json" when posting JSON bodies. |
| Coverage report empty or never generated | Missing coverage-report goal. The test goal alone runs tests but doesn’t aggregate coverage. Add both goals in the <execution> block. |
ClassNotFoundException at test boot |
MUnit version incompatible with Mule runtime. MUnit 3.x requires Mule 4.4+. Pin both in <properties> and run mvn clean install -U. |
| Mock fires but error handler is bypassed | typeId doesn’t match a registered error type. Use the canonical form (DB:CONNECTIVITY, HTTP:TIMEOUT) or your own mapped type. Typos compile silently — test the unhappy path before trusting the assertion. |
10 · RecapChapter Summary
Three-phase lifecycle
Arrange → Execute → Assert. Only behavior is optional. Pass/Fail terminal states.
munit:behavior— set-event, mocksmunit:execution— flow-refmunit:validation— assertions- Surefire XML on pass · AssertionError on fail
Intercept without changing flow
Match by doc:name (convenient) or config-ref (stable).
mock-when→ controlled payloadmunit-tools:error→ typed Mule errorspy+verify-call- 3 mocks for Scatter-Gather
Diagram before coding
Every terminal state of every flow = one test case. No state goes untested.
- 200 OK · happy path
- 404 · empty result
- 400 · invalid input
- 503 · upstream failure
70/20/10 balance
Most tests live at the base · fewer integration tests · minimal E2E. Total < 5 min.
- Unit: 20–50+ · <1s each
- Integration: 5–10 · ~5–30s
- E2E: 2–5 · minutes
- Slow suite = skipped suite
Three levels · one gate
Application · resource · flow. Start at 80/70/60 · ratchet up over time.
munit-maven-plugin(not extensions!)coverage-reportgoal requiredfailBuild=truein CI- HTML report at
target/site/munit/
The five common errors
Mock not invoked · flow not found · null payload · empty coverage · classloader.
- Match
doc:nameexactly - Always declare
mediaType - Add the
coverage-reportgoal - Pin MUnit version + Mule runtime
The runnable Mule project now ships with customer-api-v2-test-suite.xml — covering every terminal state plus the Customer 360 Scatter-Gather. The pom.xml is wired with coverage gates at 80/70/60. mvn clean test runs the lot in seconds.