Table of Contents

Unit-test code that uses SshSharp

SshClient, SshSession, SftpClient and the other types are sealed, and SshSharp has no interfaces for them on purpose: an interface can be added later without breaking anyone, but changing one after it ships breaks everyone who implements it. Test your logic through a small interface of your own instead, and keep the SshSharp code behind it thin.

When to use

  • Your code decides things based on what a server says (parses output, chooses what to run next, handles failures).
  • You want fast tests that need no server, no network and no credentials.

Put a seam in front of SSH

Define the operations your code needs, in your terms, and implement them once with SshSharp:

/// <summary>The few remote operations the application needs: the seam tests replace.</summary>
public interface IRemoteShell
{
    Task<RemoteResult> RunAsync(string command, CancellationToken cancellationToken);
}

public sealed record RemoteResult(int? ExitStatus, string Output, string Error);

/// <summary>The production implementation: a thin wrapper over SshSharp, too small to need tests of its own.</summary>
public sealed class SshRemoteShell(SshClient client) : IRemoteShell
{
    public async Task<RemoteResult> RunAsync(string command, CancellationToken cancellationToken)
    {
        var result = await client.RunCommandAsync(command, cancellationToken);
        return new RemoteResult(result.ExitStatus, result.StandardOutputText, result.StandardErrorText);
    }
}

Write the logic against the seam

/// <summary>The logic worth testing: it depends on the seam, not on SSH.</summary>
public sealed class DiskMonitor(IRemoteShell shell)
{
    public async Task<bool> IsAlmostFullAsync(string mount, CancellationToken cancellationToken)
    {
        var result = await shell.RunAsync($"df -P {Commands.Quote(mount)} | tail -1", cancellationToken);
        if (result.ExitStatus != 0)
        {
            throw new InvalidOperationException($"df failed: {result.Error.Trim()}");
        }

        var usedPercent = int.Parse(result.Output.Split(' ', StringSplitOptions.RemoveEmptyEntries)[4].TrimEnd('%'),
            System.Globalization.CultureInfo.InvariantCulture);
        return usedPercent >= 90;
    }
}

Fake it in tests

/// <summary>A scripted fake: answers by command, and records what was run.</summary>
public sealed class FakeRemoteShell(Dictionary<string, RemoteResult> answers) : IRemoteShell
{
    public List<string> Commands { get; } = [];

    public Task<RemoteResult> RunAsync(string command, CancellationToken cancellationToken)
    {
        Commands.Add(command);
        return Task.FromResult(answers.TryGetValue(command, out var answer) ? answer : new RemoteResult(127, "", "command not found"));
    }
}
// In a test (any framework): no server, no network.
var shell = new FakeRemoteShell(new()
{
    ["df -P '/var' | tail -1"] = new(0, "/dev/sda1 1000 950 50 95% /var\n", ""),
});

var almostFull = await new DiskMonitor(shell).IsAlmostFullAsync("/var", CancellationToken.None);

if (!almostFull || shell.Commands.Count != 1)
{
    throw new InvalidOperationException("Expected a full disk after one command.");   // Assert.That(...) in your framework
}

Test configuration logic directly

The configuration types parse from strings and resolve with explicit inputs, so tests need no files:

// ssh_config logic is testable without files or a network: parse a string, resolve with a fixed home and user.
var config = SshConfig.Parse("""
    Host app-*
      User deploy
      ProxyJump bastion.example.com
    """);
var resolved = config.Resolve("app-7", new SshConfigResolveOptions { HomeDirectory = "/home/test", LocalUser = "test" });

if (resolved.User != "deploy" || resolved.ProxyJump is not ["bastion.example.com"])
{
    throw new InvalidOperationException("The configuration does not route app hosts through the bastion.");
}
// known_hosts content, checked in memory.
byte[] key = [0, 0, 0, 11, .. "ssh-ed25519"u8, 0, 0, 0, 32, .. new byte[32]];
var file = KnownHostsFile.Parse($"app1.example.com ssh-ed25519 {Convert.ToBase64String(key)}\n");

var result = file.Check("app1.example.com", 22, "ssh-ed25519", key);   // KnownHostsCheckResult.Match

SshHostKeyVerifier and SshKeySigner are abstract classes: your own verifiers and signers are testable like any other class.

Integration tests

For tests against a real server, run OpenSSH in a container (the repository's tests/docker and scripts/test-server.ps1 show one way), and connect with a pinned host key or a known_hosts file the test writes. SshClient.ConnectAsync also takes any Stream, so a test can connect to a server it runs in the same process; a package with such an in-memory test server is planned (#240).

Notes and pitfalls

  • Keep the wrapper thin. It should translate, not decide; then it needs no unit tests of its own, only integration tests.
  • Model results, not exceptions only. A command that exits non-zero is a result; let the fake return one, so the failure path is tested.
  • IFileSystem. Code that works with remote files can take an IFileSystem: SftpFileSystem in production, an in-memory file system (System.IO.Abstractions' MockFileSystem) in tests. See IFileSystem and IFileProvider.
  • Dependency injection. ISshClientFactory and ISshConnectionProvider return SshClient, so faking them does not avoid the server; fake your seam instead.

See also