ConfIT is intentionally declarative-first. The JSON DSL handles the majority of real-world test scenarios — requests, mocks, matching, variable flow — without any C# code. The extension points in this document exist for the minority of cases that genuinely need code: custom auth schemes, request signing, side effects, domain-specific assertions, conditional logic that can't be expressed in JSON.
Reach for the DSL first. Reach for these when the DSL can't express it.
See Auth Profiles for the complete auth reference: bearer, OAuth2 client credentials, API key, custom header keys, OAuth2 WireMock testing pattern, YAML-based header verification, IAuthTokenProvider interface, custom C# implementations, and when to use each approach.
ConfIT emits diagnostic messages (skip notices, matcher details) during test execution. By default these go to Console. To route them to xUnit's ITestOutputHelper — so they appear in your IDE's test output pane — implement the adapter and pass it to BaseTest.
Interface:
public interface ITestOutputLogger
{
void Log(string msg);
}Minimal adapter — the complete implementation:
public class TestOutputLogger : ITestOutputLogger
{
private readonly ITestOutputHelper _output;
public TestOutputLogger(ITestOutputHelper output) =>
_output = output;
public void Log(string msg) =>
_output.WriteLine(msg);
}Pass it in your test class constructor:
public UserTests(TestSuiteFixture fixture, ITestOutputHelper output)
: base(fixture.Context, new TestOutputLogger(output))
{ }Full wiring context is shown in suite-setup.md.
📄 Live example: User.ComponentTests/SetUp/TestOutputLogger.cs
Before reaching for this:
ITestProcessoris a legacy escape hatch. The majority of cases it was historically used for — passing a created resource's ID into a later request, injecting dynamic values into paths or bodies — are now fully covered by theextract+{{inject}}DSL. Try those first. If you can express it declaratively, do so.ITestProcessormay be removed or deprecated in a future version.Only use
ITestProcessorwhen you have exhausted the declarative options and genuinely need C# — request signing, writing to an external system, conditional mutations based on runtime state, or side effects that have no DSL equivalent. See variable-extraction-and-injection.md to confirmextractand{{inject}}cannot cover your case before proceeding.
ITestProcessor lets you run code immediately before a request is sent and immediately after a response is received.
public interface ITestProcessor
{
void Before(TestApi testApi);
void After(TestApi testApi, JToken actualResponse);
}
public interface ITestProcessorFactory
{
ITestProcessor GetTestProcessor(string testName);
}Called after variable injection but before the HTTP request is sent. Mutations to testApi — path, body, headers — take effect on the outgoing request. By the time Before fires, any {{variables}} in the test definition have already been substituted; your mutations layer on top.
Example — computing a request signature:
public class SignedRequestProcessor : ITestProcessor
{
public void Before(TestApi testApi)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var signature = ComputeSignature(testApi.Request.Body, timestamp);
testApi.Request.Headers["X-Timestamp"] = timestamp;
testApi.Request.Headers["X-Signature"] = signature;
}
public void After(TestApi testApi, JToken actualResponse) { }
}Called after the HTTP response is received, before assertions run. actualResponse is the parsed JSON body. Use After for side effects: writing response data to a shared store, triggering an audit log, or capturing state for diagnostics. It fires regardless of whether the test will pass or fail — do not treat it as a success callback.
ITestProcessorFactory.GetTestProcessor(string testName) is called once per test. Return the appropriate processor for that test name, or null if the test needs no processor.
Example factory:
public class TestProcessorFactory : ITestProcessorFactory
{
private readonly SuiteConfig _config;
public TestProcessorFactory(SuiteConfig config) => _config = config;
public ITestProcessor GetTestProcessor(string testName) => testName switch
{
"ShouldCreateASignedOrder" => new SignedRequestProcessor(),
"ShouldAuditUserCreation" => new AuditWriterProcessor(_config),
_ => null
};
}Pass the factory through the fixture and into BaseTest. When using the bootstrapper, inject the factory via the with expression on the context record:
// Fixture — add factory to the bootstrapped context
public TestSuiteFixture()
{
var suite = SuiteBootstrapper.ForComponent<Startup>("suite.config.yaml");
Context = suite.Context with
{
ProcessorFactory = new TestProcessorFactory(suite.Context.Config)
};
_suite = suite;
}
public TestSuiteContext Context { get; }
// Test class constructor — unchanged
: base(fixture.Context, new TestOutputLogger(output))When using the adapter chain directly, populate TestSuiteContext with the factory:
Context = new TestSuiteContext(
HttpClient: initializer.TestHttpClient,
Config: suiteConfig,
ProcessorFactory: new TestProcessorFactory(suiteConfig),
Filter: cfg.ToTestFilter(),
ResultCollector: new TestResultCollector());If no test in a suite needs a processor, pass null — this is the common case and both example suites do exactly that.
When the same domain-specific assertion recurs across many tests, register it as a named matcher. Once registered, it is available by name in the semantic block of any test in that suite, exactly like built-in matchers.
SemanticMatcherFunc signature:
// Return null on success; return a failure message string on failure
public delegate string? SemanticMatcherFunc(JToken value, string? parameter);The second argument is the parameter parsed from name(param) syntax in the DSL — it is null for plain name usage.
Example — validating a proprietary domain ID format (DOM-{n}):
When using the bootstrapper, pass the dictionary as customMatchers:
_suite = SuiteBootstrapper.ForComponent<Startup>("suite.config.yaml",
customMatchers: new Dictionary<string, SemanticMatcherFunc>
{
["isDomainId"] = (token, _) =>
token.Value<string>()?.StartsWith("DOM-") == true
? null
: $"Expected domain ID starting with 'DOM-' but got: {token}"
});When wiring manually, set SuiteConfig.CustomMatchers directly:
suiteConfig.CustomMatchers = new Dictionary<string, SemanticMatcherFunc>
{
["isDomainId"] = (token, _) =>
token.Value<string>()?.StartsWith("DOM-") == true
? null
: $"Expected domain ID starting with 'DOM-' but got: {token}"
};Usage in the DSL:
"response": {
"statusCode": 201,
"body": {},
"matcher": {
"semantic": {
"domainRef": "isDomainId"
}
}
}Constraints:
- Custom matcher names must not conflict with built-in names. An attempt throws
ArgumentExceptionbefore any HTTP call. - Custom matchers are scoped to the suite where they are registered — they are not shared across suites.
- The
parameterargument isnullwhen the DSL uses plain"isDomainId"syntax. It receives the parsed string when the DSL uses"isDomainId(someParam)".
For the full matcher catalogue and the built-in names that are reserved, see matchers-and-patterns.md.