MuleSoft

MuleSoft Mastery: From Zero to Hero | Ch.04

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…

MuleSoft Mastery: From Zero to Hero | Ch.04
Chapter 04 · MuleSoft from Zero to Hero

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.

SeriesMuleSoft Zero → Hero Chapter04 of 12 PublishedMar 12, 2026 Read~24 min MUnit Mocking Coverage CI/CD

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.

Companion source code

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.

github.com/nestaconnect/mulesoft-from-zero-to-hero

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.

❌ WITHOUT

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
✅ WITH MUNIT

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 test runs in <60s per PR
  • 80%+ flow coverage enforced
  • Confidence to ship on Fridays
What is MUnit?

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.

Version compatibility

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.

pom.xml — properties, dependencies, plugin
<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.

MUNIT TEST EXECUTION · STATE MACHINE START ① ARRANGE munit:behavior set-event · mock-when optional ② EXECUTE munit:execution flow-ref required ③ ASSERT munit:validation assert-that · equalTo required ✅ PASS Surefire report ❌ FAIL AssertionError Only munit:behavior is optional · execution and validation are required Pass result = all assertions succeed · Fail = first assertion error halts the test
Every MUnit test · three phases · two terminal states · isolated and reproducible

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.

HOW MOCK-WHEN INTERCEPTS CONNECTOR CALLS ❌ WITHOUT MOCK · integration test (hits real DB) YOUR FLOW get-customer-flow flow-ref under test DB:SELECT real connector live JDBC call 🗄 REAL DATABASE must be running! slow · fragile · seed data Brittle · network-dependent · slow · hard to simulate failures ✅ WITH MOCK-WHEN · unit test (intercepted by MUnit proxy) YOUR FLOW get-customer-flow unchanged ✓ DB:SELECT intercepted matched by doc:name 🎭 MUNIT MOCK PROXY controlled payload [{ id: 1, name: ‘Alice’ }] 🗄 REAL DATABASE never called ✓
Mock interception · the flow XML never changes · only the processor is swapped at test time

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:

STATE DIAGRAM · GET /customers/{id}/v2 4 terminal states · 4 test cases REQUEST 🔍 VALIDATE ID matches /^[1-9][0-9]*$/ Choice router · validation invalid ✗ ⏹ 400 BAD REQUEST INVALID_ID TERMINAL · Test 3 valid ✓ 🗄 DB:SELECT WHERE id = :id inside try scope DB:CONNECTIVITY ⏹ 503 UNAVAILABLE DB_UNAVAILABLE TERMINAL · Test 4 ❓ !isEmpty(payload)? Choice router found check empty ✗ ⏹ 404 NOT FOUND CUSTOMER_NOT_FOUND TERMINAL · Test 2 found ✓ ⏹ 200 OK Customer JSON TERMINAL · Test 1 ONE TERMINAL STATE = ONE TEST CASE Test 1 — 200 OK · happy path · mock returns row ⚠️ Test 2 — 404 · mock returns empty list Test 3 — 400 · invalid id + verify-call times=”0″ 💥 Test 4 — 503 · mock throws DB:CONNECTIVITY
Diagram every flow first · each terminal state = one test · no terminal state goes untested

06 · Test SuiteAll 4 Terminal States — Complete Code

6.1 Test 1 — 200 OK (happy path)

customer-api-v2-test-suite.xml — Test 1
<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>
Mock matching · fragility tip

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

Test 2 — empty DB → 404 envelope
<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.

Test 3 — verify-call times=”0″
<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.

Test 4 — mock throws typed Mule error
<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")]'/>
About 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.

Customer 360 — 3 route mocks
<!-- 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.

THE MULE TESTING PYRAMID · COUNTS & TIMING E2E · FUNCTIONAL Postman · deployed env ~2–5 minutes each INTEGRATION TESTS MUnit + real H2 DB or sandbox APIs ~5–10 ~5–30s each UNIT TESTS · ALL MOCKED Most tests live here No external deps · 100% controlled blazing fast 20–50+ <1s each SLOW FAST 🎯 AIM FOR · 70% unit · 20% integration · 10% functional · TOTAL SUITE < 5 MINUTES
More unit tests, fewer E2E · keep the suite fast or developers will skip it

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).

COVERAGE MAP · COMPONENT-LEVEL TRACKING get-customer-v2-flow Coverage: 80% · 4/5 components ✓ COVERED set-variable ✓ COVERED choice ✓ COVERED db:select ✓ COVERED ee:transform ✗ GAP logger (perf) customer-360-flow Coverage: 100% · 5/5 components ✓ COVERED scatter-gather ✓ COVERED route 0 · db:select ✓ COVERED route 1 · db:select ✓ COVERED route 2 · transform ✓ COVERED choice (merge) CI VERDICT · APPLICATION COVERAGE Total: 9/10 components · 90% · threshold 80% ✅ BUILD PASSES
Coverage map · every component tracked · CI fails the build when threshold breached

8.1 The Maven Plugin — Coverage Gate Configuration

Right plugin · right job

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.

pom.xml — coverage plugin
<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

bash · Maven test commands
# 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:

SymptomLikely 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

01 · ANATOMY

Three-phase lifecycle

Arrange → Execute → Assert. Only behavior is optional. Pass/Fail terminal states.

  • munit:behavior — set-event, mocks
  • munit:execution — flow-ref
  • munit:validation — assertions
  • Surefire XML on pass · AssertionError on fail
02 · MOCKING

Intercept without changing flow

Match by doc:name (convenient) or config-ref (stable).

  • mock-when → controlled payload
  • munit-tools:error → typed Mule error
  • spy + verify-call
  • 3 mocks for Scatter-Gather
03 · STATE MAP

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
04 · PYRAMID

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
05 · COVERAGE

Three levels · one gate

Application · resource · flow. Start at 80/70/60 · ratchet up over time.

  • munit-maven-plugin (not extensions!)
  • coverage-report goal required
  • failBuild=true in CI
  • HTML report at target/site/munit/
06 · GOTCHAS

The five common errors

Mock not invoked · flow not found · null payload · empty coverage · classloader.

  • Match doc:name exactly
  • Always declare mediaType
  • Add the coverage-report goal
  • Pin MUnit version + Mule runtime
Get the source code

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.

github.com/nestaconnect/mulesoft-from-zero-to-hero
Up next · Chapter 05A

Anypoint Design Center — Mastering the API Designer Interface

Continue reading →

Leave a comment

Your email address will not be published. Required fields are marked *