Testing pipelines without burning tokens
FlowMason has first-class test support. FMTestMocks + FMPipelineTest let you unit-test any pipeline without making real LLM calls, hitting live SOQL, or burning API credits.
The basic pattern
Every FlowMason test follows the same three-step structure: mock the LLM (and any other external calls), activate the mocks, run the pipeline, assert the output.
@IsTest(SeeAllData=false)
private class AccountSummarizeTest {
@IsTest
static void testSummarizeHappyPath() {
// 1. Arrange — mock the LLM call for the 'summarize' stage
FMTestMocks.mockLLM('summarize', 'Three-bullet summary of Acme Corp');
// Mock a SOQL query response
FMTestMocks.mockSOQL('FROM Account', new List<SObject>{
new Account(Name = 'Acme Corp', Industry = 'Technology')
});
// Activate the HTTP mock (required for any LLM call)
FMTestMocks.apply();
// 2. Act
ExecutionResult result = FMPipelineTest.run(
'account-summarize-v1',
new Map<String, Object>{ 'accountId' => '001000000000001' }
);
// 3. Assert
FMPipelineTest.assertSuccess(result);
Assert.areEqual(
'Three-bullet summary of Acme Corp',
FMPipelineTest.outputGet(result, 'content'),
'Expected the mocked LLM response to flow through'
);
}
} Mock types
LLM mocks
The most common mock. mockLLM(stageId, response) intercepts the LLM call for a specific stage and returns your canned response. Use '*' as the stage id to match any LLM call in the pipeline:
@IsTest
static void testWithWildcardLLMMock() {
// '*' matches any stage id — useful for pipelines with multiple LLM stages
FMTestMocks.mockLLM('*', 'Generic LLM response');
FMTestMocks.apply();
ExecutionResult result = FMPipelineTest.run('multi-stage-pipeline', input);
FMPipelineTest.assertSuccess(result);
} HTTP callout mocks
Use mockHTTP(domain, statusCode, responseBody) for stages that make outbound HTTP calls (like http_callout stages calling Slack, webhooks, or external APIs):
@IsTest
static void testWithHttpCallout() {
// Mock an outbound HTTP callout
FMTestMocks.mockHTTP('api.example.com', 200, '{"status":"ok","data":"processed"}');
// Mock a failing callout (non-2xx response)
FMTestMocks.mockHTTP('slow-api.example.com', 503, '{"error":"Service Unavailable"}');
FMTestMocks.apply();
ExecutionResult result = FMPipelineTest.run('enrichment-pipeline', input);
FMPipelineTest.assertSuccess(result);
} Error simulation
Test your pipeline's error handling by simulating provider failures:
@IsTest
static void testProviderTimeout() {
// Simulate a provider timeout
FMTestMocks.mockLLMError('summarize', 'ProviderTimeoutException', 'Request timed out');
FMTestMocks.apply();
ExecutionResult result = FMPipelineTest.run(
'account-summarize-v1',
new Map<String, Object>{ 'accountId' => '001000000000001' }
);
Assert.isFalse(result.isSuccess(), 'Expected pipeline to fail on timeout');
Assert.isTrue(
result.errorMessage.contains('timed out'),
'Error message should describe the timeout'
);
} Configuration in tests
FMConfig.setForTest overrides any FM_Config__mdt value without requiring the FlowMason_Config_Admin permission. Changes are scoped to the test context:
@IsTest
static void testWithCustomConfig() {
// Override FM_Config__mdt values in tests — no permission needed
FMConfig.setForTest('defaultMaxTokens', '500', 'number');
FMConfig.setForTest('providerMaxRetries', '1', 'number');
FMConfig.setForTest('defaultProvider', 'openai', 'string');
FMTestMocks.mockLLM('*', 'Mocked response');
FMTestMocks.apply();
ExecutionResult result = FMPipelineTest.run('my-pipeline', input);
FMPipelineTest.assertSuccess(result);
} Testing the invocable surface
Test the @InvocableMethod surface directly — this validates the exact code path that Flow Builder calls:
@IsTest(SeeAllData=false)
private class FMSummarizeInvocableTest {
@IsTest
static void testInvocableSurface() {
// Test the Flow invocable surface directly
FMTestMocks.mockLLM('*', 'Three bullets about this account');
FMTestMocks.apply();
FMSummarize.Request req = new FMSummarize.Request();
req.recordId = '001000000000001';
req.prompt = 'Summarize for the AE team';
req.maxTokens = 300;
List<FMSummarize.Response> responses =
FMSummarize.summarize(new List<FMSummarize.Request>{ req });
Assert.areEqual(1, responses.size());
FMSummarize.Response r = responses[0];
Assert.isTrue(String.isBlank(r.errorMessage), 'Expected no error: ' + r.errorMessage);
Assert.areEqual('Three bullets about this account', r.summary);
}
} Assertion helpers
FMPipelineTest provides assertion helpers so your tests read clearly and fail with useful messages:
// FMPipelineTest assertion helpers:
// Assert the pipeline completed successfully:
FMPipelineTest.assertSuccess(result);
// Assert a specific failure message:
FMPipelineTest.assertFailure(result, 'timed out');
// Get a named output field (null-safe):
Object value = FMPipelineTest.outputGet(result, 'content');
String text = (String) FMPipelineTest.outputGet(result, 'summary');
// Get a stage-specific output:
Object stageOut = FMPipelineTest.stageOutputGet(result, 'classify', 'category');
// Assert token counts were tracked:
Assert.isTrue(result.totalInputTokens > 0);
Assert.isTrue(result.totalOutputTokens > 0); What gets mocked — and what doesn't
FMTestMocks intercepts at four seams:
| Seam | What's mocked |
|---|---|
BaseNode.callLLM() | All LLM calls regardless of provider |
SoqlQuery.execute() | SOQL queries with mockSOQL(fromClause, records) |
HttpCallout.execute() | Outbound HTTP calls with mockHTTP(domain, status, body) |
FMSummarize.summarizeCore() | The FMSummarize primitive specifically |
DML operations, FMConfig reads, and all other FlowMason framework code run for real in the test — only external I/O is intercepted. This gives you high confidence without the flakiness of real external calls.
Testing guidelines
- One test per behavior, not per method. Test what the pipeline should do, not what it calls.
- Use specific stage id mocks for multi-stage pipelines. Wildcard mocks hide stage-routing bugs.
- Always test the error path. Mock provider timeouts and HTTP 5xx responses — that's when pipelines in production need to be most reliable.
- Use
@IsTest(SeeAllData=false). Tests should never depend on org data. UsemockSOQLto supply test data. - Reset between test methods.
FMTestMocksresets automatically between@IsTestmethods — no manual cleanup needed.