Skip to content

Unit Testing ​

Java Action Testing ​

The deltafi-action-kit-test dependency provides classes to simplify writing unit tests for actions. It includes a helper class for setting up the tests and assertions to verify the results. This dependency is automatically included when using the org.deltafi.plugin-convention gradle plugin.

TIP

deltafi-action-kit-test also carries a deprecated test framework built around ActionTest, TransformActionTest, EgressActionTest, and ActionResultAssertions, which is removed in 3.0. If your tests still use it, see Action unit tests move to DeltaFiTestRunner for the replacements.

Test Setup ​

The org.deltafi.test.content.ActionTestContext class prepares everything the action under test needs. It does not run the action; the test calls the action's own method. ActionTestContext provides:

  • A memory-backed content store used by the action under test
  • resource(path) to store a test resource as content, named after the file with a media type inferred from its extension, and content(name, mediaType).fromString(...), .fromBytes(...), .fromResource(...) or .empty() to choose the name and media type yourself
  • resourceAsString(path) and resourceAsBytes(path) to read expected output
  • actionContext() for a pre-populated ActionContext wired to the content store, and actionContextBuilder() with the same defaults for tests that need to change a field such as the memo

ActionTestContext.create() reads resources from the root of the test classpath, and ActionTestContext.withResourceFolder("helloworld") reads them from src/test/resources/helloworld/. As a shortcut, ActionTestContext.forTestClass(MyTest.class) uses a folder named after the test class. Declare the context as a field so JUnit creates a fresh one, with its own content store, for each test.

Below is a sample unit test showing the standard setup.

java
package org.deltafi.helloworld.actions;

import org.deltafi.actionkit.action.ResultType;
import org.deltafi.actionkit.action.transform.TransformInput;
import org.deltafi.test.content.ActionTestContext;
import org.junit.jupiter.api.Test;

import java.util.*;

import static org.deltafi.test.asserters.DeltaFiAssertions.*;

class HelloWorldTransformActionTest {

    // create the action to test
    HelloWorldTransformAction helloWorldTransformAction = new HelloWorldTransformAction();

    // resources come from src/test/resources/helloworld/
    ActionTestContext ctx = ActionTestContext.withResourceFolder("helloworld");

    @Test
    void simpleTest() {
        // store test resources as content; resource() names the content after the file and infers its media type
        TransformInput testInput = TransformInput.builder()
                .content(List.of(ctx.resource("input.json"), ctx.content("notes", "text/plain").fromString("content data")))
                .metadata(Map.of("key", "value"))
                .build();

        // create parameters to pass to the action
        HelloWorldTransformParameters params = new HelloWorldTransformParameters();

        // call the action's own method
        ResultType resultType = helloWorldTransformAction.transform(ctx.actionContext(), params, testInput);

        // compare against expected output kept next to the input
        assertThat(resultType).isTransformResult()
                .content(0).loadStringIsEqualTo(ctx.resourceAsString("expected.json"));
    }
}

DeltaFiTestRunner, the older setup class, still works.

Result Verification ​

The org.deltafi.test.asserters package provides classes used to verify the results from executing an Action. Start from DeltaFiAssertions.assertThat(result), state the result type you expect, and chain the assertions for that type. A static import of DeltaFiAssertions.* covers every result type and works alongside AssertJ's Assertions.* when both are wildcard imports. Importing assertThat by name from either class hides the other's assertThat methods.

The following code shows sample usage of a subset of the assertions.

java
package org.deltafi.helloworld.actions;

import org.deltafi.actionkit.action.transform.TransformInput;
import org.deltafi.common.types.ActionContext;
import org.deltafi.test.asserters.TransformResultsAssert;
import org.deltafi.test.content.ActionTestContext;
import org.junit.jupiter.api.Test;

import java.util.Map;

import static org.deltafi.test.asserters.DeltaFiAssertions.*;

class HelloWorldTransformActionTest {

    HelloWorldTransformAction helloWorldTransformAction = new HelloWorldTransformAction();
    ActionTestContext ctx = ActionTestContext.withResourceFolder("helloworld");
    ActionContext actionContext = ctx.actionContext();

    @Test
    void simpleTest() {
        ResultType resultType = helloWorldTransformAction.transform(actionContext, new HelloWorldTransformParameters(), TransformInput.builder().build());

        // expect a transform result and verify all the parts
        assertThat(resultType).isTransformResult()
                .hasContentMatchingAt(0, "name", "mediaType", "expected this content to be saved")
                .addedMetadata("new-key", "value")
                .deletedMetadataKey("deleted-key")
                .hasMetric("some-metric", 1, Map.of("tag", "tag-value"));

        // navigate into one piece of content
        assertThat(resultType).isTransformResult()
                .content(0).hasName("name").hasMediaType("mediaType").loadStringIsEqualTo("expected this content to be saved");

        // expect a filter result with an exact cause
        assertThat(resultType).isFilterResult()
                .hasCause("filtered reason");

        // expect an error result with a cause that matches the regex
        assertThat(resultType).isErrorResult()
                .hasCauseLike(".*errored reason substring.*");

        // expect an egress result
        assertThat(resultType).isEgressResult();
    }

    @Test
    void splitTest() {
        ResultType resultType = helloWorldTransformAction.transform(actionContext, new HelloWorldTransformParameters(), TransformInput.builder().build());

        // child(i, assertions) runs the full transform asserter on one child and stays on the parent
        assertThat(resultType).isTransformResults()
                .hasChildrenSize(2)
                .child(0, c -> c.hasContentMatchingAt(0, "part-0", "text/plain", "first").addedMetadataExactly(Map.of("index", "0")))
                .child(1, c -> c.hasContentMatchingAt(0, "part-1", "text/plain", "second").addedMetadataExactly(Map.of("index", "1")));

        // or keep the parent in a variable when there is a lot to check per child
        TransformResultsAssert children = assertThat(resultType).isTransformResults();
        children.child(0).content(0).hasName("part-0").loadStringIsEqualTo("first");
    }
}

Navigation methods return the asserter for the part, so later calls in the chain apply to that part: content(i) returns a content asserter, child(i) returns the asserter for one child of a TransformResults or IngressResult, and contents(), children(), metadata(), and annotations() return AssertJ list and map asserts for anything else, such as contents().anySatisfy(...) or metadata().doesNotContainKey("secret"). To check several parts in one chain, pass the assertions as a lambda: content(i, c -> ...) and child(i, c -> ...) run them on that part and return the parent asserter.

Metadata, annotations, and deleted keys are plain maps and lists, so the full AssertJ vocabulary is available through metadata(), annotations(), and deletedMetadataKeys(), for example metadata().doesNotContainKey("secret") or deletedMetadataKeys().isEmpty(). Pass a lambda, such as metadata(m -> m.containsKey("id")), to stay on the result and keep chaining. The common checks also have shortcuts on the result asserter:

  • addedMetadata(key, value) and addedMetadata(map) check that the result includes those entries and allow others, such as defaults the test doesn't care about
  • addedMetadataExactly(map) checks that the metadata is exactly that map, in any order
  • metadataIsEmpty(), addedAnnotation(key, value), addedAnnotations(map), annotationsIsEmpty(), deletedMetadataKey(key), and deletedMetadataKeys(keys) (includes those keys)

A failed assertion is labeled with what it checked, for example [Metadata] or [Child at index 1: Metadata]. To add your own context, use AssertJ's as(...) before the assertion:

java
TransformResultAssert.assertThat(resultType)
        .as("after enrichment")
        .addedMetadata("new-key", "value");

Plugin Context Tests ​

Most actions are best tested directly, as shown above. When you need to check how the plugin wires together, a plain @SpringBootTest starts your plugin's full application context, with your actions and any other beans, without the core, Valkey, or MinIO.

deltafi-action-kit-test doesn't bring in Spring's test support, so add it to your plugin. Exclude android-json, which duplicates the org.json classes the action kit already provides:

groovy
dependencies {
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'com.vaadin.external.google', module: 'android-json'
    }
}
java
package org.deltafi.example;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class ExamplePluginContextTest {
    @Autowired
    private ExampleTransformAction action;

    @Test
    void actionIsWired() {
        assertNotNull(action);
    }
}

With Spring's test support on the classpath, deltafi-action-kit-test sets two defaults for every Spring test context, whether it runs from Gradle or your IDE:

  • actions.auto-start=false, so the plugin runtime is never built or started. The plugin doesn't register with the core, consume actions, or send heartbeats.
  • A strict TestArtifactFactory for @PluginArtifact handles and declared artifacts. Every artifact resolves, but reading the content of one the test didn't supply throws an IllegalStateException that names the artifact. An artifact with a defaultResource serves that resource instead.

As at plugin startup, each artifact's onUpdate callbacks fire once after every bean is built. A callback that fails, such as one that reads an artifact the test didn't supply, is logged as a warning and the context still starts, as the plugin would in production. A constructor that reads an artifact the test didn't supply fails the context, so supply content for those artifacts. To simulate a later version change, call fireUpdate() on the TestArtifactHandle.

When a test reads artifact content, replace the ArtifactFactory bean with a configured TestArtifactFactory. Add .strict() to keep failing on reads of artifacts you didn't register; without it they resolve to empty content:

java
import org.deltafi.actionkit.artifact.ArtifactFactory;
import org.deltafi.test.artifact.TestArtifactFactory;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.convention.TestBean;

@SpringBootTest
class ExampleArtifactContextTest {
    @TestBean
    private ArtifactFactory artifactFactory;

    static ArtifactFactory artifactFactory() {
        return TestArtifactFactory.builder().artifact("routing-rules.json", "{}").strict().build();
    }
}

The test class must be in a package covered by your plugin's @SpringBootApplication. The context also needs the META-INF/build-info.properties that the org.deltafi.plugin-convention Gradle plugin generates.

Go Action Testing ​

Beta

The Go action kit is currently in beta. Interfaces may change in future releases.

The deltafi-go-action-kit/v2 package includes test helpers for unit testing actions without external services (Valkey, MinIO). The package provides:

  • NewTestContext(did) — creates a TestContext with a pre-configured ActionContext wired to an in-memory content store
  • TestContext.Content(name, mediaType, data) — creates an ActionContent from a string for use as input
  • TestContext.NewDeltafile(contents...) — creates a *Deltafile wired to the test context
  • TestContext.NewTimedIngressDeltafile() — creates a *TimedIngressDeltafile for ingress action tests
  • RunTransform, RunEgress, RunIngress — generic test runners that execute actions and return *ActionEvent
  • RequireTransform, RequireSingleTransform, RequireError, RequireFilter, RequireEgress, RequireIngress — result assertions on *ActionEvent
  • MockLookupClient — configurable mock for lookup table queries with full query semantics
  • MockArtifactClient — configurable mock for artifact registry access

Test Setup ​

go
package actions

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    actionkit "gitlab.com/deltafi/deltafi/deltafi-go-action-kit/v2"
)

func TestMyTransformAction(t *testing.T) {
    // Create a test context with an in-memory content store
    tc := actionkit.NewTestContext("test-did")

    // Store test content
    content := tc.Content("input.txt", "text/plain", "hello world")

    // Create a Deltafile with the test content
    df := tc.NewDeltafile(content)

    // Execute the action and get the result event
    event := actionkit.RunTransform(t, &MyTransformAction{}, df, MyTransformParams{})

    // Assert it's a single transform result
    te := event.RequireSingleTransform(t)

    // Verify the output content
    require.Len(t, te.Content, 1)
    data := te.RequireContentString(t, 0)
    assert.Contains(t, data, "hello world")
    assert.Contains(t, data, "transformed")

    // Verify metadata and annotations
    assert.Equal(t, "transformValue", te.Metadata["transformKey"])
}

Result Verification ​

The *ActionEvent returned by the test runners provides assertion methods for each result type:

go
func TestFilterResult(t *testing.T) {
    tc := actionkit.NewTestContext("test-did")
    df := tc.NewDeltafileWithMetadata(
        map[string]string{"filter": "true"},
        tc.Content("in.txt", "text/plain", "data"))

    event := actionkit.RunTransform(t, &MyTransformAction{}, df, MyTransformParams{})
    event.RequireFilter(t, "Filter due to filter metadata")
}

func TestErrorResult(t *testing.T) {
    tc := actionkit.NewTestContext("test-did")
    df := tc.NewDeltafile() // no content

    event := actionkit.RunTransform(t, &MyTransformAction{}, df, MyTransformParams{})
    event.RequireError(t, "No content provided")
}

Testing Split Actions ​

Split actions are transform actions that create children via df.AddChild(name):

go
func TestMySplitAction(t *testing.T) {
    tc := actionkit.NewTestContext("test-did")
    c1 := tc.Content("file1.txt", "text/plain", "data1")
    c2 := tc.Content("file2.txt", "text/plain", "data2")
    df := tc.NewDeltafile(c1, c2)

    event := actionkit.RunTransform(t, &MySplitAction{}, df, MySplitParams{})

    transforms := event.RequireTransform(t)
    assert.Len(t, transforms, 2)
}

Testing Ingress Actions ​

go
func TestMyIngressAction(t *testing.T) {
    tc := actionkit.NewTestContext("test-did")
    tc.ActionContext.Memo = "5" // simulate previous memo
    timed := tc.NewTimedIngressDeltafile()

    event := actionkit.RunIngress(t, &MyIngressAction{}, timed, MyIngressParams{})

    ie := event.RequireIngress(t)
    assert.Len(t, ie.IngressItems, 1)
    assert.Equal(t, "6", ie.Memo)
}

Mock Lookup Client ​

go
func TestWithLookup(t *testing.T) {
    tc := actionkit.NewTestContext("test-did")
    tc.SetLookupClient(t, &actionkit.MockLookupClient{
        Tables: []actionkit.LookupTable{
            {
                Name: "my_table",
                Rows: []actionkit.LookupRow{
                    {"name": "Alice"},
                    {"name": "Bob"},
                },
            },
        },
    })

    df := tc.NewDeltafile(tc.Content("in.txt", "text/plain", "data"))

    event := actionkit.RunTransform(t, &MyTransformAction{}, df, MyTransformParams{})
    te := event.RequireSingleTransform(t)
    assert.NotEmpty(t, te.Metadata["lookupResult"])
}

C++ Action Testing ​

Beta

The C++ action kit is currently in beta. Interfaces may change in future releases.

The deltafi/testing.hpp header provides test helpers for unit testing C++ actions without external services (Valkey, MinIO). It includes:

  • InMemoryContentStore — an in-memory content store replacing MinIO
  • new_test_context(did) — creates a TestContext whose action_context is pre-configured and wired to an in-memory content store
  • TestContext::content(name, media_type, data) — creates an ActionContent from a string for use as input
  • new_deltafile(tc, data, name, media_type, metadata) — builds a DeltaFileInput (input content and metadata) holding one content item; a DeltaFileInput can also be filled in directly
  • new_timed_ingress_deltafile(tc, memo) — sets the memo a timed ingress action reads
  • run_transform, run_egress, run_ingress, run_join — test runners that execute actions through the same dispatch path as the worker (a fresh context per run that carries the test context's start time, DeltaFile construction, param deserialization, orphan-content GC, and the fault barrier) and return the resulting ActionEvent; running the same test context again keeps earlier output
  • require_transform, require_single_transform, require_error, require_filter, require_egress, require_ingress — result-type assertions; require_metadata, require_annotation, require_content, require_metric, require_message — field-level assertions
  • MockLookupClient — configurable mock for lookup table queries; MockArtifactClient — configurable mock for artifact registry access that records publishes and watch registrations and fires registered watches; TestContext::use_lookup / use_artifact wire them so a runner-dispatched action can reach them
  • LogCapture — captures lines logged through the spdlog default logger while in scope, with contains / count assertions, and restores the previous logger afterwards
  • InMemoryContentStore::fail_after_nth(n) — make the Nth save() throw to exercise error paths; size() / save_count() introspect the store (e.g. to assert orphan content was GC'd)

run_transform(action, actx, params, input) passes explicit parameters; the other runners, and run_transform without params, use a default-constructed Params when the action declares one. The assertion helpers throw std::runtime_error on a mismatch, which fails the enclosing Catch2 test case.

The examples below form one Catch2 test file. They test the hello-world actions defined in Transform Action, Egress Action, and Timed Ingress Action, and the split, join, and lookup actions shown alongside their tests.

Test Setup ​

cpp
#include <catch2/catch_test_macros.hpp>
#include <deltafi/testing.hpp>
#include "actions/hello_world_cpp_transform_action.hpp"

using namespace deltafi;
using namespace deltafi::testing;

TEST_CASE("Transform produces expected output") {
    // Create a test context with an in-memory content store
    auto tc = new_test_context("test-did");

    // Build the input content and metadata
    auto input = new_deltafile(tc, "hello world", "input.txt", "text/plain", {{"key", "value"}});

    // Execute the action and get the result event
    HelloWorldCppTransformAction action;
    auto event = run_transform(action, tc.action_context, input);

    // Assert it's a single transform result
    const auto& te = require_single_transform(event);

    // Verify the output content
    REQUIRE(te.content.size() == 1);
    auto data = require_content(te, "transform-named-me").load_string();
    CHECK(data.find("hello world") != std::string::npos);
    CHECK(data.find("did a great job") != std::string::npos);

    // Verify metadata and annotations
    require_metadata(te, "transformKey", "transformValue");
    require_annotation(te, "transformAnnotation", "value");
}

To pass parameters, construct them and use the four-argument runner:

cpp
TEST_CASE("Transform honors its parameters") {
    auto tc = new_test_context("test-did");
    auto input = new_deltafile(tc, "hello world", "input.txt", "text/plain");

    HelloWorldCppTransformAction action;
    HelloWorldCppTransformAction::Params params;
    params.convert_to_upper = true;

    auto event = run_transform(action, tc.action_context, params, input);
    const auto& te = require_single_transform(event);
    CHECK(te.content[0].load_string().find("HELLO WORLD") != std::string::npos);
}

Result Verification ​

The test assertion functions validate the result type and return the typed event for inspection:

cpp
TEST_CASE("Filter result on metadata") {
    auto tc = new_test_context("test-did");
    auto input = new_deltafile(tc, "data", "in.txt", "text/plain", {{"filter", "true"}});

    HelloWorldCppTransformAction action;
    auto event = run_transform(action, tc.action_context, input);
    require_filter(event, "Filter due to filter metadata");
}

TEST_CASE("Error result on no content") {
    auto tc = new_test_context("test-did");
    DeltaFileInput input;  // no content

    HelloWorldCppTransformAction action;
    auto event = run_transform(action, tc.action_context, input);
    require_error(event, "No content provided");
}

Testing Split Actions ​

Split actions are transform actions that create children via df.add_child(name).

actions/split_action.hpp:

cpp
#pragma once

#include <deltafi/plugin>

class SplitAction {
public:
    deltafi::DeltaFile& transform(deltafi::DeltaFile& df) {
        for (const auto& content : df.content()) {
            df.add_child(content.name).add_content(content);
        }
        return df;
    }
};

DELTAFI_ACTION(SplitAction, "SplitAction", "Splits each content item into its own child DeltaFile")
cpp
#include "actions/split_action.hpp"

TEST_CASE("Split creates one child per content") {
    auto tc = new_test_context("test-did");
    DeltaFileInput input;
    input.content = {tc.content("file1.txt", "text/plain", "data1"),
                     tc.content("file2.txt", "text/plain", "data2")};

    SplitAction action;
    auto event = run_transform(action, tc.action_context, input);

    const auto& transforms = require_transform(event);
    REQUIRE(transforms.size() == 2);
    CHECK(transforms[0].name == "file1.txt");
    CHECK(transforms[1].content[0].load_string() == "data2");
}

Testing Egress Actions ​

cpp
#include "actions/hello_world_cpp_egress_action.hpp"

TEST_CASE("Egress succeeds") {
    auto tc = new_test_context("test-did");
    auto input = new_deltafile(tc, "payload", "out.txt", "text/plain");

    HelloWorldCppEgressAction action;
    auto event = run_egress(action, tc.action_context, input);
    require_egress(event);
}

Testing Ingress Actions ​

cpp
#include "actions/hello_world_cpp_timed_ingress_action.hpp"

TEST_CASE("Ingress advances the memo") {
    auto tc = new_test_context("test-did");
    new_timed_ingress_deltafile(tc, "5");  // simulate previous memo

    HelloWorldCppTimedIngressAction action;
    auto event = run_ingress(action, tc.action_context);

    const auto& ie = require_ingress(event);
    CHECK(ie.ingress_items.size() == 1);
    CHECK(ie.memo == "6");
}

Testing Join Actions ​

run_join runs join and then transform, as the worker does when the flow configures a join. Set a join configuration on the context so the action is dispatched as a join.

actions/join_content_action.hpp:

cpp
#pragma once

#include <vector>

#include <deltafi/plugin>

class JoinContentAction {
public:
    deltafi::DeltaFile& join(std::vector<deltafi::DeltaFile>& inputs, deltafi::DeltaFile& joined) {
        for (auto& input : inputs) {
            for (const auto& content : input.content()) {
                joined.add_content(content);
            }
        }
        return joined;
    }

    deltafi::DeltaFile& transform(deltafi::DeltaFile& df) {
        df.passthrough_content();
        return df;
    }
};

DELTAFI_ACTION(JoinContentAction, "JoinContentAction", "Joins the content of its inputs")
cpp
#include "actions/join_content_action.hpp"

TEST_CASE("Join merges its inputs") {
    auto tc = new_test_context("test-did");
    tc.action_context.join = JoinConfiguration{};

    std::vector<DeltaFileInput> inputs = {
        new_deltafile(tc, "part1", "a.txt", "text/plain"),
        new_deltafile(tc, "part2", "b.txt", "text/plain"),
    };

    JoinContentAction action;
    auto event = run_join(action, tc.action_context, inputs);

    const auto& te = require_single_transform(event);
    CHECK(te.content.size() == 2);
}

Mock Lookup Client ​

actions/lookup_count_action.hpp:

cpp
#pragma once

#include <string>

#include <deltafi/plugin>

class LookupCountAction {
public:
    deltafi::DeltaFile& transform(deltafi::DeltaFile& df) {
        auto results = df.context().lookup_client->lookup("my_table");
        df.passthrough_content();
        df.add_metadata("lookupResult", std::to_string(results.total_count));
        return df;
    }
};

DELTAFI_ACTION(LookupCountAction, "LookupCountAction", "Records the row count of my_table")
cpp
#include "actions/lookup_count_action.hpp"

TEST_CASE("Action uses lookup table") {
    auto tc = new_test_context("test-did");
    MockLookupClient lookup;
    LookupResults results;
    results.total_count = 2;
    results.rows = {
        {{"name", "Alice"}},
        {{"name", "Bob"}},
    };
    lookup.set_results("my_table", results);
    tc.use_lookup(lookup);

    auto input = new_deltafile(tc, "data", "in.txt", "text/plain");

    LookupCountAction action;
    auto event = run_transform(action, tc.action_context, input);
    const auto& te = require_single_transform(event);
    require_metadata(te, "lookupResult", "2");
}

Mock Artifact Client ​

MockArtifactClient records every publish (name, namespace, data, content type, and PublishOptions) and every artifact watch (name, namespace, alias, optional default, and handler). Because the watch methods are part of ArtifactClientBase, an ArtifactAware action's configure_artifacts registers against the mock, as do the plugin-level DELTAFI_WATCH_ARTIFACT / DELTAFI_WATCH_SHARED_ARTIFACT watches through deltafi::register_artifact_watches(mock). A test then fires an artifact or an error into the registered handler:

cpp
TEST_CASE("Action reloads its rules when the artifact changes") {
    RulesAction action;  // configure_artifacts(deltafi::ArtifactClientBase&) watches rules.json
    MockArtifactClient artifacts;
    action.configure_artifacts(artifacts);

    auto watch = artifacts.find_watch("rules.json");
    REQUIRE(watch.has_value());
    REQUIRE(watch->default_artifact.has_value());

    FetchedArtifact fetched;
    fetched.data = {'[', ']'};
    artifacts.fire_watch("rules.json", fetched);  // or fire_watch_error("rules.json", "...")
}

TEST_CASE("Action publishes its model to the shared namespace") {
    auto tc = new_test_context("test-did");
    MockArtifactClient artifacts;
    tc.use_artifact(artifacts);

    auto input = new_deltafile(tc, "data", "in.txt", "text/plain");
    PublishModelAction action;
    run_transform(action, tc.action_context, input);

    auto publishes = artifacts.publishes();
    REQUIRE(publishes.size() == 1);
    CHECK(publishes[0].shared);
    CHECK(publishes[0].content_type == "application/json");
    REQUIRE(publishes[0].options.has_value());
    CHECK(publishes[0].options->make_active == true);
}

find_shared_watch, fire_shared_watch, and fire_shared_watch_error do the same for watches on the shared namespace. Raw on_update / on_shared_update callbacks are recorded too, with version_handler set instead of handler. fire_watch passes them a VersionChangedEvent carrying the fired artifact's manifest version and the plugin owner given to the MockArtifactClient constructor (default test-plugin); the error helpers skip them. Firing a watch that was never registered throws, so a misspelled name fails the test.

Capturing Log Output ​

deltafi::testing::LogCapture (from deltafi/testing/log_capture.hpp, also included by deltafi/testing.hpp) keeps log lines out of test output and lets a test assert them. While it is in scope, every line logged through the spdlog default logger is recorded instead of printed; when it goes out of scope, the previous default logger is restored.

cpp
TEST_CASE("Action warns on an empty input") {
    LogCapture logs;
    // ... run the action ...
    CHECK(logs.contains(spdlog::level::warn, "empty input"));
    CHECK(logs.count(spdlog::level::err, "") == 0);
}

lines() returns each captured line's level, message, and formatted text, and clear() discards what has been captured so far. The capturing logger keeps the replaced logger's level, so call spdlog::set_level inside the guard to capture debug lines. Create and destroy the guard while no other thread is logging (for example, before starting and after stopping any threads the test runs), because spdlog does not support swapping the default logger during concurrent logging.

Contact US