Test an extension
From a solution created by ankus new:
dotnet testankus new Hello --test-framework xunit and --test-framework nunit generate
xUnit.net v3 and NUnit test projects. The installed dotnet new ankus and
ankus-worker templates accept the same options. MSTest remains the default.
All three frameworks discover individual backend cases, honor IgnoreReason
and use an asynchronous fixture to dispose the cluster and published extension.
Reports retain each backend test’s managed identity; xUnit also shows its theory
arguments after that name.
The test project contains two kinds of tests. Managed tests call ordinary C# methods directly. Backend tests publish the extension with Native AOT, start an isolated PostgreSQL cluster, and exercise its generated SQL functions.
Backend testing requires PostgreSQL with server headers and the
Native AOT toolchain.
Extension builds also require LLVM Clang 20 or later with matching libclang;
see publishing prerequisites.
The fixture uses the extension project’s evaluated AnkusPostgresMajor and
AnkusPgConfigPath, including imported and configuration-specific properties.
Without a version setting, it discovers PostgreSQL 18. Register a nonstandard
installation with ankus init --pg18 /path/to/pg_config. Missing prerequisites
fail initialization; tests are never silently skipped.
Plain dotnet test -p:AnkusPostgresMajor=17 selects matching headers and a
PostgreSQL 17 server. Use -p:AnkusPgConfigPath=/path/to/pg_config for a specific
installation; its reported major is used when AnkusPostgresMajor is absent.
The testing package carries these build settings into the test host; --no-build
retains the settings from its previous build. An explicit
fixture Installation or ANKUS_TEST_PG_CONFIG takes
precedence. A selected path that reports a different requested major is rejected.
Use ankus test --pg 17 to build and test against another registered version,
or ankus test --all to run against every registered major. Pass normal runner
arguments after --, for example:
ankus test --pg 18 -- --filter "FullyQualifiedName~BackendTests" --report-trxThe command selects matching headers and servers, keeps reports per major, and cleans up fixtures after an aborted host. See test command options.
For SQL files with expected text output, use ankus regress --pg 18.
The SQL regression guide covers
PostgreSQL’s native comparator, setup scripts, and reviewing changed expectations.
On supported Unix systems, ankus regress --pg 18 --valgrind runs the server
under Memcheck. See native memory diagnostics
for prerequisites and reading the server log.
Declare tests inside the extension
Section titled “Declare tests inside the extension”Use [PgTest] on a synchronous static void method in a partial class.
The method and its containing classes must be accessible from generated code
in the extension assembly: public, internal, or protected internal for
members and nested classes.
using Ankus;
public static partial class BackendChecks{ [PgTest] public static void AdditionInsidePostgres() => PgAssert.AreEqual(42, Spi.ExecuteScalar<int>("SELECT 19 + 23"));
[PgTest(ExpectedError = "expected failure")] public static void ExpectedFailure() => throw new InvalidOperationException("expected failure");}The method body executes inside the PostgreSQL backend. You can use SPI,
memory contexts and other backend APIs there. Test frameworks do not load inside
an extension, so check results with PgAssert, as pgrx tests use assert_eq!.
A failed check names the expected and actual values. Besides equality, null and
exception checks, it has AreEqual with a floating-point tolerance, ordinal
Contains, DoesNotContain, StartsWith and EndsWith for text, and
HasCount and IsEmpty for sequences. PgAssert.ThrowsSqlState
runs its work in a subtransaction and returns the PostgreSQL error, so the test
can inspect it and keep going in the same transaction:
[PgTest]public static void DivisionByZeroIsReported(){ PgException error = PgAssert.ThrowsSqlState(PgSqlStates.DivisionByZero, () => Spi.ExecuteScalar<int>("SELECT 1 / 0")); PgAssert.AreEqual("division by zero", error.Message);}Methods accept no SQL arguments;
injected PgFunctionContext and PgMemoryContext parameters remain available.
Methods and containing classes must be accessible to generated code and cannot
be generic. Every containing class must be partial. Invalid declarations produce
an error at the declaration or option that needs correction; valid sibling tests
and production exports remain available.
BackendChecks.PostgresTests.Cases contains immutable PgTestCase values.
Reading this catalog does not run the enclosing class’s static constructor or
its test methods. Reserve the nested name PostgresTests for generated code.
The scaffold connects these cases to MSTest’s ordinary data-driven tests:
public static IEnumerable<TestDataRow<PgTestCase>> NativeCases => BackendChecks.PostgresTests.Cases.Select(test => new TestDataRow<PgTestCase>(test) { DisplayName = test.Name, IgnoreMessage = test.IgnoreReason, });
[TestMethod][DynamicData(nameof(NativeCases))]public Task DeclaredTestsExecuteInPostgres(PgTestCase test) => extension.RunTestAsync(test, context.CancellationToken);Start extension once during test-class initialization with test publication
enabled:
extension = await PostgresExtensionTest.StartAsync(new PostgresExtensionTestOptions{ ProjectPath = projectPath, IncludeTests = true, PostgreSqlConfiguration = ["my_extension.mode = 'test'"],}, context.CancellationToken);Dispose it during test-class cleanup. The options also select Installation,
Configuration (default Release for direct fixtures, or the configuration
selected by ankus test), BuildProperties, SharedPreload and Port.
BuildProperties participate in project selection and the native publication.
When invoked through ankus test, forwarded MSBuild properties become the
default so the managed test build and nested fixture publish use the same values.
The fixture uses the selected installation’s headers and server together.
Its publish uses the .NET SDK that the extension project’s directory selects
through global.json, as dotnet publish there would, even when another SDK
runs the tests.
For ordinary dotnet test, set DataDirectoryBase to put cluster data beneath
a chosen directory. The fixture creates and removes its own unique child;
your parent directory and other contents remain. With ankus test, use
--pgdata ./test-data to choose the base for every fixture in the command.
Server logs remain in the extension project’s bin/ankus-test-logs/ directory.
Set ReuseSchema = true to reuse the last successfully published installation
SQL and its embedded metadata while recompiling function bodies. With
ankus test --no-schema, this is the default; an explicit fixture option takes
precedence. Run normally first to generate the schema for the selected
configuration, PostgreSQL major and test-publication mode. Changes to native
declarations require another normal run. SQL-only changes take effect when
schema generation is enabled again.
Each case runs in its own transaction, which rolls back after success, failure
or cancellation. ExpectedError matches the server’s primary message exactly;
a different error or unexpected success fails the test. Failure diagnostics
include the original managed name, database exception and backend session log.
[PgTest(IgnoreReason = "reason")] supplies an explicit framework skip reason.
Directly invoking an ignored case fails; the host must report the skip.
Set PgTest.SearchPath to control name resolution inside a backend test. For
example, [PgTest(SearchPath = ["pg_catalog", PgSearchPath.ExtensionSchema, "pg_temp"])]
uses the extension’s installation schema. PostgreSQL restores the caller’s path
when the function returns or raises an error. An empty array clears the path;
null preserves the caller’s setting. The reserved extension-schema token makes
test publications non-relocatable, just as it does for
ordinary functions. Normal publications
exclude backend test functions and their search paths.
Volatility, ParallelSafety, SecurityDefiner and Cost set the test
function’s execution options, as pgrx’s #[pg_test] passes #[pg_extern]
options through. For example, [PgTest(SecurityDefiner = true)] runs the test
with the installing role’s privileges.
Names in reports retain their C# identity. Generated SQL names fit PostgreSQL’s
identifier limit, even for long C# method names. An explicit [PgSchema] selects
the function schema; otherwise the fixture uses the installed extension schema,
including a schema supplied by its control file.
Normal dotnet publish excludes test functions. The fixture sets
AnkusIncludeTests to match IncludeTests and rejects a conflicting
BuildProperties value; true also defines the ANKUS_TESTS compilation
symbol. Put SQL functions that only backend tests
call inside #if ANKUS_TESTS, as pgrx code uses #[cfg(feature = "pg_test")]. Other test hosts
can consume the same framework-neutral catalog and fixture. Use
[PgBenchmark] and ankus bench for measured backend work.
Property tests
Section titled “Property tests”PgPropertyRunner checks a condition against many generated inputs inside the
backend, as pgrx’s PgTestRunner does with proptest:
[PgTest]public static void DatesRoundTrip() => new PgPropertyRunner().Run( PgGenerators.Number<int>().Select(PgDate.FromRawSaturating), date => PgAssert.AreEqual(date, Spi.ExecuteScalar<PgDate>("SELECT $1", SpiParameter.Create(date))));The test throws to fail. Each input runs in its own subtransaction, so a PostgreSQL error fails that input and rolls back its work while the run continues; passing inputs keep their changes until the test’s transaction rolls back. Query cancellation stops the run.
When an input fails, the runner shrinks it before reporting: numbers move toward
zero, text and arrays lose elements, and choices move toward the first
alternative. The PgPropertyException names the smallest failing input, its
error and the seed. Set PgPropertyOptions.Seed to replay the same inputs.
Cases defaults to 256 as in proptest.
PgGenerators provides integers of any width with Number<T>(), floating-point
values including NaN and the infinities, text, arrays, booleans, SQL NULL and
fixed choices. Compose them with Select, Where and LINQ query syntax; the
results still shrink.
Declaration diagnostics
Section titled “Declaration diagnostics”Backend-test errors identify the requirement and its authored source. Correct the highlighted method, containing class, conflicting attribute or metadata value. Each valid test keeps its discovery catalog and normal native publication behavior even when another declaration is invalid.
| Diagnostic | Requirement |
|---|---|
ANKUS291 |
PostgreSQL backend test must be static. |
ANKUS292 |
PostgreSQL backend test must be synchronous. |
ANKUS293 |
PostgreSQL backend test cannot be generic. |
ANKUS294 |
PostgreSQL backend test must have an implementation. |
ANKUS295 |
PostgreSQL backend test must return void. |
ANKUS296 |
PostgreSQL backend test must be accessible. |
ANKUS297 |
PostgreSQL backend test cannot have SQL arguments. |
ANKUS298 |
PostgreSQL backend test context must be passed by value. |
ANKUS299 |
PostgreSQL backend test has a conflicting role. |
ANKUS300 |
PostgreSQL backend test requires a class. |
ANKUS301 |
PostgreSQL backend test cannot have a generic container. |
ANKUS302 |
PostgreSQL backend test cannot be file-local. |
ANKUS303 |
PostgreSQL backend test container must be accessible. |
ANKUS304 |
PostgreSQL backend test container must be partial. |
ANKUS305 |
PostgreSQL backend test owner has a reserved name. |
ANKUS306 |
PostgreSQL backend test catalog name is already declared. |
ANKUS307 |
Invalid PostgreSQL backend test expected error. |
ANKUS308 |
Invalid PostgreSQL backend test ignore text. |
ANKUS309 |
PostgreSQL backend test requires a nonempty ignore reason. |
Editors offer Make static for ANKUS291, Make synchronous for
ANKUS292, Make internal for ANKUS296, ANKUS302 and ANKUS303,
Make partial for ANKUS304, and Pass by value for an in or
ref readonly context (ANKUS298).
ExpectedError = null means successful execution is expected; an empty string
remains an exact expected message. IgnoreReason = null runs the test, while a
non-null reason must explain its omission. Both options preserve exact Unicode
and embedded quotes, and reject zero characters or unpaired surrogates.
Use the fixture
Section titled “Use the fixture”PostgresExtensionTest is included
in the Ankus.Testing package:
await using PostgresExtensionTest extension = await PostgresExtensionTest.StartAsync( projectPath, cancellationToken: context.CancellationToken);
await extension.Cluster.RunInTransactionAsync("addition", async (connection, transaction, token) =>{ await using var command = new NpgsqlCommand("SELECT public.add(19, 23)", connection, transaction); Assert.AreEqual(42, await command.ExecuteScalarAsync(token));}, context.CancellationToken);The fixture passes the same PostgreSQL installation to native compilation and
cluster startup, then executes CREATE EXTENSION ... CASCADE, so extensions
that your control file requires are created first when the installation
provides them. It leaves the selected installation untouched. Supply an explicit PostgresInstallation to test a
particular version.
If your control file declares a custom SQL directory, the fixture remaps it
inside its temporary storage. It stages the declared installation SQL, upgrades
and secondary controls without changing your author file or writing to its
original destination, including absolute paths.
For workers and shared-memory extensions, enable shared preload in the fixture:
await using PostgresExtensionTest extension = await PostgresExtensionTest.StartAsync( projectPath, sharedPreload: true, cancellationToken: context.CancellationToken);The fixture loads the published library before starting backends, then creates
the extension’s SQL objects. ankus new MyWorker --background-worker generates
this setup and a test that observes a worker running in a separate process.
RunInTransactionAsync opens a connection and rolls back when the callback
finishes, including after assertion failures. Parallel tests use independent
connections. The generated fixture shares one cluster for its test class and
disposes it after all tests finish.
Choose a test port
Section titled “Choose a test port”Fixtures choose an available port automatically so independent test runs can
start in parallel. For a predictable port while debugging, save a test base
with ankus init --pg18 /path/to/pg_config --base-testing-port 33000, then
request the resolved port explicitly:
var registry = new PostgresRegistry();PostgresInstallation installation = await registry.GetAsync(18, context.CancellationToken);await using PostgresExtensionTest extension = await PostgresExtensionTest.StartAsync( projectPath, sharedPreload: false, port: registry.GetTestPort(18), installation: installation, cancellationToken: context.CancellationToken);This starts the fixture on 33018. You can also pass a literal port from 1
through 65535, or set PostgresTestClusterOptions.Port when creating a cluster
directly. An occupied explicit port fails startup; it never switches to another
port. Use different ports for concurrent fixtures, or leave port selection automatic.
Failures and cleanup
Section titled “Failures and cleanup”Unhandled managed exceptions become PostgreSQL errors. The generated recovery test uses a savepoint to recover from an expected error and verifies the next query on the same connection.
Build logs and PostgreSQL logs remain under the extension project’s
bin/ankus-test-logs/ for seven days after their last write; a later run
removes them and leaves any other files in the directory. A failed test’s exception carries its session’s log
lines and, when its backend died, the server’s report about that process. The
cluster and temporary published library are removed on disposal. A failed build or extension load fails initialization and cleans up
the resources it created.
While a test runs, INFO, NOTICE and WARNING messages from the test’s own session
that contain TMSG: go to the test’s output as they arrive. Use
PgLog.Info("TMSG: ...") to trace a backend test; other messages stay in the
server log. pgrx scans the server log instead, so it also echoes LOG-level
messages and messages from other processes.
On Windows, the fixture also collects its server’s Windows Event Log messages.
Older PostgreSQL versions use that destination when the test host runs as a
service. The test account needs read access to the Application event log;
each cluster’s diagnostics remain separate and are retained in its server log.
Call ReadServerLog() to refresh that file while a Windows cluster is running.
Disposing the cluster also collects its shutdown messages.
Test clusters run with fsync = off, as PostgreSQL’s own test clusters do.
PostgreSQL crash recovery still works, because the operating system keeps the
written WAL; only durability across an operating system crash is given up. Add
fsync = on to PostgreSqlConfiguration to test behavior that depends on it.
Set UseValgrind on the fixture or cluster options, or run ankus test --valgrind, to run the server under Valgrind’s Memcheck on Linux or macOS.
Test clusters create the superuser postgres, as ankus start does, so one
connection string works against either. Set UserName on the cluster options
for another name.
Like pgrx’s, test clusters log messages at info and statements that take over a
second, and use a C collation with UTF-8 encoding. Unlike pgrx, they do not log
every statement; add log_statement = 'all' to PostgreSqlConfiguration when a
failure report should list each statement. Settings you supply follow the
defaults, so they win.
PostgresTestClusterOptions.StartupTimeout bounds initialization, server start and
creation of the test database together. Extension fixtures use the 180-second
default, which matches PostgreSQL’s own TAP test timeout, so a busy shared disk
does not fail an otherwise healthy startup; Valgrind adds another minute.
If another process takes an automatically selected TCP port during startup, the fixture retries with a new isolated cluster and port. It allows up to three attempts within the original startup timeout, retaining each failed attempt’s log and removing its data and sockets. Configuration errors and other startup failures are reported immediately; cancellation stops further attempts.
PostgreSQL 18 and later use a per-cluster extension search path. For earlier versions, the fixture makes and removes an isolated copy of the installation.