Docs / Unit Testing
Unit Testing

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.

Why this matters: LLM responses are non-deterministic and take 3–10 seconds per call. Testing with real providers makes your test suite slow, flaky, and expensive. Mock them out — test the pipeline logic, not the LLM.

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.

Apex — happy path test
@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:

Apex — wildcard LLM mock
@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):

Apex — HTTP mock
@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:

Apex — error path test
@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:

Apex — config overrides in tests
@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:

Apex — invocable test
@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:

Apex — FMPipelineTest helpers
// 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:

SeamWhat'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. Use mockSOQL to supply test data.
  • Reset between test methods. FMTestMocks resets automatically between @IsTest methods — no manual cleanup needed.

What's next