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, andcontent(name, mediaType).fromString(...),.fromBytes(...),.fromResource(...)or.empty()to choose the name and media type yourselfresourceAsString(path)andresourceAsBytes(path)to read expected outputactionContext()for a pre-populatedActionContextwired to the content store, andactionContextBuilder()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.
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.
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)andaddedMetadata(map)check that the result includes those entries and allow others, such as defaults the test doesn't care aboutaddedMetadataExactly(map)checks that the metadata is exactly that map, in any ordermetadataIsEmpty(),addedAnnotation(key, value),addedAnnotations(map),annotationsIsEmpty(),deletedMetadataKey(key), anddeletedMetadataKeys(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:
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:
dependencies {
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'com.vaadin.external.google', module: 'android-json'
}
}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
TestArtifactFactoryfor@PluginArtifacthandles and declared artifacts. Every artifact resolves, but reading the content of one the test didn't supply throws anIllegalStateExceptionthat names the artifact. An artifact with adefaultResourceserves 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:
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 aTestContextwith a pre-configuredActionContextwired to an in-memory content storeTestContext.Content(name, mediaType, data)— creates anActionContentfrom a string for use as inputTestContext.NewDeltafile(contents...)— creates a*Deltafilewired to the test contextTestContext.NewTimedIngressDeltafile()— creates a*TimedIngressDeltafilefor ingress action testsRunTransform,RunEgress,RunIngress— generic test runners that execute actions and return*ActionEventRequireTransform,RequireSingleTransform,RequireError,RequireFilter,RequireEgress,RequireIngress— result assertions on*ActionEventMockLookupClient— configurable mock for lookup table queries with full query semanticsMockArtifactClient— configurable mock for artifact registry access
Test Setup
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:
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):
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
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
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 MinIOnew_test_context(did)— creates aTestContextwhoseaction_contextis pre-configured and wired to an in-memory content storeTestContext::content(name, media_type, data)— creates anActionContentfrom a string for use as inputnew_deltafile(tc, data, name, media_type, metadata)— builds aDeltaFileInput(input content and metadata) holding one content item; aDeltaFileInputcan also be filled in directlynew_timed_ingress_deltafile(tc, memo)— sets the memo a timed ingress action readsrun_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 resultingActionEvent; running the same test context again keeps earlier outputrequire_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 assertionsMockLookupClient— 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_artifactwire them so a runner-dispatched action can reach themLogCapture— captures lines logged through the spdlog default logger while in scope, withcontains/countassertions, and restores the previous logger afterwardsInMemoryContentStore::fail_after_nth(n)— make the Nthsave()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
#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:
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:
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:
#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")#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
#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
#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:
#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")#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:
#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")#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:
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.
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.

