Like all good designs, Ports-and-Adapters makes things more testable. Everything is tested in a tight edit/build/test cycle except for the "real" adapters. The "real" adapters don't change very much, so we test them on a slower cadence.
"Don't change very much" isn't very reassuring though. I don't think about the real adapters much, but I at least want something to tell me that my real adapters aren't changing. If they need to change, grab my attention so I can run the focused integration tests.
Arlo Belshee suggested record/reply tests. Here's an example, in C# with HTTP:
Record-and-passthrough integration testing
While developing the adapter we run "focused integration tests", testing the adapter it against the real dependency. For each test we record the HTTP requests and responses.
Since these tests are slow/flaky/expensive we don't run them in the edit/build/test cycle, but only when actively working on the adapter.
Verify-and-replay isolated testing
While doing development on the rest of the system, or while refactoring in the real adapter, we run the real adapter against the recorded messages. This test tells us that the adapter's behavior hasn't changed (in any way that we test for), without the speed/reliability/cost of talking to the real service.
#NoMocks
This is not a mock. How so? And why not use a mock?A mock encodes what we know about the thing we're mocking. We write our understanding in code. If our understanding doesn't match the real service, our tests can pass but the system is will fail in production.
New requirements mean extending the mock. As the mock grows, it needs good design to keep from becoming unmaintainable. This recorder is cheap to extend: write a new test, run it, save the results.
Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class HtmlRequestMessageData | |
{ | |
public readonly string Content; | |
public readonly HttpMethod Method; | |
public readonly Uri RequestUri; | |
[JsonConstructor] | |
HtmlRequestMessageData(HttpMethod method, Uri requestUri, string content) | |
{ | |
this.Method = method; | |
this.RequestUri = requestUri; | |
this.Content = content; | |
} | |
public static async Task<HtmlRequestMessageData> FromHttpRequestMessage(HttpRequestMessage request) | |
{ | |
return new HtmlRequestMessageData(request.Method, request.RequestUri, | |
await (request.Content?.ReadAsStringAsync()).OrDefault()); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class HtmlResponseMessageData | |
{ | |
public readonly string Content; | |
public readonly string ReasonPhrase; | |
public readonly HttpStatusCode StatusCode; | |
[JsonConstructor] | |
public HtmlResponseMessageData(string content, HttpStatusCode statusCode, string reasonPhrase) | |
{ | |
this.Content = content; | |
this.StatusCode = statusCode; | |
this.ReasonPhrase = reasonPhrase; | |
} | |
public static async Task<HtmlResponseMessageData> FromHttpResponseMessage(HttpResponseMessage response) | |
{ | |
return new HtmlResponseMessageData(await (response.Content?.ReadAsStringAsync()).OrDefault(), | |
response.StatusCode, | |
response.ReasonPhrase); | |
} | |
public async Task<HttpResponseMessage> ToHttpResponseMessage() | |
{ | |
return new HttpResponseMessage | |
{ | |
Content = new StringContent(this.Content), | |
ReasonPhrase = this.ReasonPhrase, | |
StatusCode = this.StatusCode | |
}; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[TestClass] | |
public class LiveIntegrationTest : TestBase | |
{ | |
readonly PassthroughAndRecordHttpMessageHandler _recordingHandler = new PassthroughAndRecordHttpMessageHandler(); | |
public TestContext TestContext { get; set; } | |
[TestCleanup] | |
public void TestCleanup() | |
{ | |
var json = JsonConvert.SerializeObject(this._recordingHandler.Recordings, Formatting.Indented); | |
RecordPlaybackTestUtilities.SaveRecordings(TestContext, json); | |
} | |
protected override HttpClientHandler GetHandler() | |
{ | |
return this._recordingHandler; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class PassthroughAndRecordHttpMessageHandler : HttpClientHandler | |
{ | |
public readonly List<Tuple<HtmlRequestMessageData, HtmlResponseMessageData>> Recordings = | |
new List<Tuple<HtmlRequestMessageData, HtmlResponseMessageData>>(); | |
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, | |
CancellationToken cancellationToken) | |
{ | |
var response = await base.SendAsync(request, cancellationToken); | |
await Record(request, response); | |
return response; | |
} | |
async Task Record(HttpRequestMessage request, HttpResponseMessage response) | |
{ | |
this.Recordings.Add(Tuple.Create( | |
await HtmlRequestMessageData.FromHttpRequestMessage(request), | |
await HtmlResponseMessageData.FromHttpResponseMessage(response))); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class PlaybackHtmlMessageHandler : HttpClientHandler | |
{ | |
readonly List<Tuple<HtmlRequestMessageData, HtmlResponseMessageData>> _recordedRequestsAndResponses; | |
int _currentRecordingIndex; | |
public PlaybackHtmlMessageHandler( | |
List<Tuple<HtmlRequestMessageData, HtmlResponseMessageData>> | |
recordedRequestsAndResponses) | |
{ | |
this._recordedRequestsAndResponses = recordedRequestsAndResponses; | |
} | |
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, | |
CancellationToken cancellationToken) | |
{ | |
Tuple<HtmlRequestMessageData, HtmlResponseMessageData> requestAndResponse = | |
this._recordedRequestsAndResponses[this._currentRecordingIndex++]; | |
requestAndResponse.Item1.Should().BeEquivalentTo(await HtmlRequestMessageData.FromHttpRequestMessage(request)); | |
return await requestAndResponse.Item2.ToHttpResponseMessage(); | |
} | |
protected override void Dispose(bool disposing) | |
{ | |
VerifyAllRequestsSent(); | |
base.Dispose(disposing); | |
} | |
public void VerifyAllRequestsSent() | |
{ | |
Assert.AreEqual(this._recordedRequestsAndResponses.Count, this._currentRecordingIndex); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[TestClass] | |
public class PlaybackIntegrationTest : TestBase | |
{ | |
PlaybackHtmlMessageHandler _playbackHandler; | |
public TestContext TestContext { get; set; } | |
[TestInitialize] | |
public void TestInitialize() | |
{ | |
var json = RecordPlaybackTestUtilities.LoadRecordings(TestContext); | |
var recordings = | |
JsonConvert.DeserializeObject<List<Tuple<HtmlRequestMessageData, HtmlResponseMessageData>>>(json); | |
this._playbackHandler = new PlaybackHtmlMessageHandler(recordings); | |
} | |
[TestCleanup] | |
public void TestCleanup() | |
{ | |
this._playbackHandler.VerifyAllRequestsSent(); | |
} | |
protected override HttpClientHandler GetHandler() | |
{ | |
return this._playbackHandler; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
static class RecordPlaybackTestUtilities | |
{ | |
static string GetRecordPath(TestContext testContext) | |
{ | |
var fileName = new StackFrame(skipFrames: 0, fNeedFileInfo: true).GetFileName(); | |
fileName.Should().NotBeNull("File name not found. Check debug info and the like."); | |
var directoryName = Path.GetDirectoryName(fileName); | |
return Path.Combine(directoryName, testContext.TestName + ".record"); | |
} | |
public static void SaveRecordings(TestContext testContext, string json) | |
{ | |
File.WriteAllText(GetRecordPath(testContext), json); | |
} | |
public static string LoadRecordings(TestContext testContext) | |
{ | |
return File.ReadAllText(GetRecordPath(testContext)); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace TaskExtensions | |
{ | |
static class _ | |
{ | |
/// <summary>Turns a null Task into a Task that returns null. For example, <code>await a?.b.OrDefault()</code></summary> | |
/// <remarks>IMO, this is a flaw in C#. It should allow `await null` to return null.</remarks> | |
public static Task<T> OrDefault<T>(this Task<T> task) | |
{ | |
return task ?? Task.FromResult(default(T)); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public abstract class TestBase | |
{ | |
protected abstract HttpClientHandler GetHandler(); | |
[TestMethod] | |
public async Task GetWeatherByZipCode() | |
{ | |
var httpClient = new HttpClient(GetHandler()); | |
var result = await httpClient.GetStringAsync("http://samples.openweathermap.org/data/2.5/weather?zip=94040,us&appid=b1b15e88fa797225412429c1c50c122a1"); | |
ApprovalTests.Approvals.Verify(result); | |
JsonConvert.DeserializeObject(result).Should().BeEquivalentTo(JsonConvert.DeserializeObject(@"{ | |
""coord"": { | |
""lon"": -122.08, | |
""lat"": 37.39 | |
}, | |
""weather"": [ | |
{ | |
""id"": 500, | |
""main"": ""Rain"", | |
""description"": ""light rain"", | |
""icon"": ""10n"" | |
} | |
], | |
""base"": ""stations"", | |
""main"": { | |
""temp"": 277.14, | |
""pressure"": 1025, | |
""humidity"": 86, | |
""temp_min"": 275.15, | |
""temp_max"": 279.15 | |
}, | |
""visibility"": 16093, | |
""wind"": { | |
""speed"": 1.67, | |
""deg"": 53.0005 | |
}, | |
""clouds"": { ""all"": 1 }, | |
""dt"": 1485788160, | |
""sys"": { | |
""type"": 1, | |
""id"": 471, | |
""message"": 0.0116, | |
""country"": ""US"", | |
""sunrise"": 1485789140, | |
""sunset"": 1485826300 | |
}, | |
""id"": 5375480, | |
""name"": ""Mountain View"", | |
""cod"": 200 | |
}")); | |
} | |
} |