Skip to main content

Public API

The package aweXpect.Snapshots.PublicApi verifies the public API of an assembly against a reviewed snapshot, so that every change of the public API shows up in a pull request:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot()
.UniqueFor.TargetFramework();

The expectation is in the namespace aweXpect, like MatchesSnapshot(). PublicApiDeclarations.GenerateFrom(…), which generates the public API without an expectation, and the options are in the namespace aweXpect.Snapshots.PublicApi.

The public API is written as C# declarations, e.g.

namespace MusicStore
{
public abstract class Album : System.IComparable<MusicStore.Album>
{
protected Album(string title) { }
public string Title { get; }
public abstract System.Collections.Generic.IEnumerable<string> Tracks();
}
}

It behaves like MatchesSnapshot() for strings, e.g. for the files and accepting changes.

Target frameworks and reference assemblies​

It usually differs between the target frameworks of a library, so keep a snapshot per target framework of the test project with .UniqueFor.TargetFramework(); each target framework of the test project loads the matching build of the library. A build that the tests cannot run on, e.g. for netstandard2.0, or a reference assembly is read from its file without loading it, and named after its target framework:

await That(new FileInfo(path)).MatchesPublicApiSnapshot().Named("netstandard2.0");

A build for a newer .NET than the tests run on needs the .NET SDK of that version, which installs its reference assemblies.

How the types in the signatures are resolved

The types in the signatures are resolved from the assemblies next to the assembly, in the output of the tests, of the .NET version it targets and of the current runtime. The NuGet packages of the library, which its build output does not contain, fill in the assemblies that are missing or only found in a lower version: they are read from the global packages folder with the versions that the deps.json next to the assembly lists. When a referenced assembly cannot be resolved, or only in a lower version, the expectation throws an InvalidOperationException that names it, instead of writing a public API that differs from machine to machine.

Options​

Options configure what is written, for the expectation and for PublicApiDeclarations.GenerateFrom(…). A failure lists the options that differ from the defaults in the "Public API options" context, e.g. ignore forwarded types.

Forwarded types​

The types that the assembly forwards to another assembly are written as [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(…))], as removing a forward breaks the consumers that were compiled against the assembly. They can be ignored:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot(o => o.Ignoring.ForwardedTypes());

Attributes​

An attribute is written when consumers can see its class, or when compilers or analyzers read it by its name, e.g. [NotNullWhen(true)] or [OverloadResolutionPriority(1)], so that also a polyfill that the library declares internally is written. An attribute that is irrelevant for the public API can be ignored everywhere, by its type or, e.g. when the tests cannot reference it, by its full name like Type.FullName:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot(o => o
.Ignoring.Attribute<EditorBrowsableAttribute>()
.Ignoring.Attribute("MusicStore.Internal.MarkerAttribute"));

[Serializable], [DllImport(…)] and [Optional] are written, although the compiler stores them as flags in the metadata instead of as attributes, as removing them breaks consumers, e.g. of serialization or interop; they can be ignored like any other attribute.

Records​

A record is written with the members that the compiler synthesizes for it, e.g. Equals, GetHashCode, ToString, Deconstruct, the copy constructor and the equality operators. The members that are marked as [CompilerGenerated] can be ignored, which keeps the primary constructor, the positional properties and the hand-written members of the record:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot(o => o.Ignoring.CompilerGeneratedMembers());

Namespaces and types​

The public API can be restricted to namespaces, including their sub-namespaces, or to types, including their nested types, and namespaces and types can be ignored. A namespace matches whole segments, so MusicStore.Generated neither includes nor ignores MusicStore.GeneratedTools, and ignoring wins over a restriction:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot(o => o
.Within.Namespace("MusicStore")
.Ignoring.Namespace("MusicStore.Generated")
.Ignoring.Type<LegacyAdapter>());

A type is selected by its type or by its full name like Type.FullName, for a generic type with any type arguments. A nested type is written with its containing type. The filters also apply to the forwarded types. An empty namespace is the global namespace.

Formatting​

Every declaration is written on a single line by default. To see a change of a single parameter as a change of a single line, write the parameters of methods, constructors, operators, delegates and indexers with more than one parameter on separate lines:

await That(typeof(Album).Assembly).MatchesPublicApiSnapshot(o => o.Formatting.ParametersOnSeparateLines());

So instead of

public Album(string title, [System.Runtime.CompilerServices.CallerMemberName] string source = "") { }

the declaration is written as

public Album(
string title,
[System.Runtime.CompilerServices.CallerMemberName] string source = "") { }

Requirements​

The assembly is read with Roslyn, so the package depends on a recent version of Microsoft.CodeAnalysis.CSharp that reads all extension members correctly. A test project that needs an older Roslyn, e.g. for testing an analyzer, cannot use the package, so verify the public API in a separate test project.