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 anIFileSystem:SftpFileSystemin production, an in-memory file system (System.IO.Abstractions'MockFileSystem) in tests. See IFileSystem and IFileProvider.- Dependency injection.
ISshClientFactoryandISshConnectionProviderreturnSshClient, so faking them does not avoid the server; fake your seam instead.
See also
- Testing and validation: how SshSharp itself is tested