diff --git a/Build/nuspec/Flurl.Http.nuspec b/Build/nuspec/Flurl.Http.nuspec index e8eb7049..20eeb5a3 100644 --- a/Build/nuspec/Flurl.Http.nuspec +++ b/Build/nuspec/Flurl.Http.nuspec @@ -2,7 +2,7 @@ Flurl.Http - 1.1.0-pre + 1.1.1-pre Flurl.Http Todd Menier http://tmenier.github.io/Flurl @@ -13,6 +13,7 @@ A fluent, portable, testable HTTP client library that extends Flurl's URL builder chain. +1.1.1 - More query param assert variations (github #102), HttpCall.Url now a Flurl.Url instance 1.1.0 - Parallel testing (github #67), better DI/IoC/mocking support (github #146), assert query params (github #102) 1.0.3 - .NET Core 1.0.1, fixed assembly references (github #131) 1.0.2 - Updated Flurl dependency to 2.2.1 https://www.nuget.org/packages/Flurl/2.2.1 diff --git a/Test/Flurl.Test.Shared/Http/RealHttpTests.cs b/Test/Flurl.Test.Shared/Http/RealHttpTests.cs index cc7ad958..62bf4095 100644 --- a/Test/Flurl.Test.Shared/Http/RealHttpTests.cs +++ b/Test/Flurl.Test.Shared/Http/RealHttpTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Flurl.Http; +using Flurl.Http.Testing; using NUnit.Framework; namespace Flurl.Test.Http @@ -208,5 +209,16 @@ public async Task can_handle_error() { FlurlHttp.Configure(c => c.ResetDefaults()); } } + + [Test] + public async Task can_comingle_real_and_fake_tests() { + // do a fake call while a real call is running + var realTask = "https://www.google.com/".GetStringAsync(); + using (new HttpTest()) { + var fake = await "https://www.google.com/".GetStringAsync(); + Assert.AreEqual("", fake); + } + Assert.AreNotEqual("", await realTask); + } } } \ No newline at end of file diff --git a/Test/Flurl.Test.Shared/Http/TestingTests.cs b/Test/Flurl.Test.Shared/Http/TestingTests.cs index 460330ff..dc6e9ec9 100644 --- a/Test/Flurl.Test.Shared/Http/TestingTests.cs +++ b/Test/Flurl.Test.Shared/Http/TestingTests.cs @@ -51,6 +51,8 @@ public async Task can_setup_multiple_responses() { .RespondWith("two") .RespondWith("three"); + HttpTest.ShouldNotHaveMadeACall(); + await "http://www.api.com/1".GetAsync(); await "http://www.api.com/2".GetAsync(); await "http://www.api.com/3".GetAsync(); @@ -61,27 +63,64 @@ public async Task can_setup_multiple_responses() { Assert.AreEqual("two", await calls[1].Response.Content.ReadAsStringAsync()); Assert.AreEqual("three", await calls[2].Response.Content.ReadAsStringAsync()); + HttpTest.ShouldHaveMadeACall(); HttpTest.ShouldHaveCalled("http://www.api.com/*").WithVerb(HttpMethod.Get).Times(3); HttpTest.ShouldNotHaveCalled("http://www.otherapi.com/*"); } [Test] public async Task can_assert_query_params() { - await "http://www.api.com?x=111&y=222&z=333".GetAsync(); - - HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParam("x"); - HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParam("y", 222); - HttpTest.ShouldHaveCalled("*").WithQueryParam("z", "*3"); - HttpTest.ShouldHaveCalled("*").WithQueryParams(new { z = 333, y = 222 }); + await "http://www.api.com?x=111&y=222&z=333#abcd".GetAsync(); + + HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParams(); + HttpTest.ShouldHaveMadeACall().WithQueryParam("x"); + HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParams("z", "y"); + HttpTest.ShouldHaveMadeACall().WithQueryParamValue("y", 222); + HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParamValue("z", "*3"); + HttpTest.ShouldHaveMadeACall().WithQueryParamValues(new { z = 333, y = 222 }); + + // without + HttpTest.ShouldHaveCalled("http://www.api.com*").WithoutQueryParam("w"); + HttpTest.ShouldHaveMadeACall().WithoutQueryParams("t", "u", "v"); + HttpTest.ShouldHaveCalled("http://www.api.com*").WithoutQueryParamValue("x", 112); + HttpTest.ShouldHaveMadeACall().WithoutQueryParamValues(new { x = 112, y = 223, z = 666 }); + + // failures + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithQueryParam("w")); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithQueryParamValue("y", 223)); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithQueryParamValue("z", "*4")); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithQueryParamValues(new { x = 111, y = 666 })); Assert.Throws(() => - HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParam("w")); + HttpTest.ShouldHaveMadeACall().WithoutQueryParams()); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithoutQueryParam("x")); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithoutQueryParams("z", "y")); Assert.Throws(() => - HttpTest.ShouldHaveCalled("http://www.api.com*").WithQueryParam("y", 223)); + HttpTest.ShouldHaveMadeACall().WithoutQueryParamValue("y", 222)); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithoutQueryParamValue("z", "*3")); + Assert.Throws(() => + HttpTest.ShouldHaveMadeACall().WithoutQueryParamValues(new { z = 333, y = 222 })); + } + + [Test] + public async Task can_assert_multiple_occurances_of_query_param() { + await "http://www.api.com?x=1&x=2&x=3&y=4#abcd".GetAsync(); + + HttpTest.ShouldHaveMadeACall().WithQueryParam("x"); + HttpTest.ShouldHaveMadeACall().WithQueryParamValue("x", new[] { 2, 1 }); // order shouldn't matter + HttpTest.ShouldHaveMadeACall().WithQueryParamValues(new { x = new[] { 3, 2, 1 } }); // order shouldn't matter + Assert.Throws(() => - HttpTest.ShouldHaveCalled("*").WithQueryParam("z", "*4")); + HttpTest.ShouldHaveMadeACall().WithQueryParamValue("x", new[] { 1, 2, 4 })); Assert.Throws(() => - HttpTest.ShouldHaveCalled("*").WithQueryParams(new { x = 111, y = 666 })); + HttpTest.ShouldHaveMadeACall().WithQueryParamValues(new { x = new[] { 1, 2, 4 } })); } [Test] diff --git a/src/Flurl.Http.Shared/HttpCall.cs b/src/Flurl.Http.Shared/HttpCall.cs index 4d17933f..19f7b3d5 100644 --- a/src/Flurl.Http.Shared/HttpCall.cs +++ b/src/Flurl.Http.Shared/HttpCall.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.Linq; using System.Net; using System.Net.Http; using Flurl.Http.Configuration; @@ -13,9 +11,12 @@ namespace Flurl.Http /// public class HttpCall { + private readonly Lazy _url; + internal HttpCall(HttpRequestMessage request, FlurlHttpSettings settings) { Request = request; Settings = settings; + _url = new Lazy(() => new Url(Request.RequestUri.AbsoluteUri)); } /// @@ -65,9 +66,9 @@ internal HttpCall(HttpRequestMessage request, FlurlHttpSettings settings) { public TimeSpan? Duration => EndedUtc - StartedUtc; /// - /// Absolute URI being called. + /// The URL being called. /// - public string Url => Request.RequestUri.AbsoluteUri; + public Url Url => _url.Value; /// /// True if a response was received, regardless of whether it is an error status. diff --git a/src/Flurl.Http.Shared/Testing/HttpCallAssertException.cs b/src/Flurl.Http.Shared/Testing/HttpCallAssertException.cs index f63046f6..945b0659 100644 --- a/src/Flurl.Http.Shared/Testing/HttpCallAssertException.cs +++ b/src/Flurl.Http.Shared/Testing/HttpCallAssertException.cs @@ -1,5 +1,6 @@ using System; -using System.Text; +using System.Collections.Generic; +using System.Linq; namespace Flurl.Http.Testing { @@ -11,21 +12,26 @@ public class HttpCallAssertException : Exception /// /// Initializes a new instance of the class. /// - /// The URL pattern. - /// The expected calls. - /// The actual calls. - public HttpCallAssertException(string urlPattern, int? expectedCalls, int actualCalls) : base(BuildMessage(urlPattern, expectedCalls, actualCalls)) { } + /// The expected call conditions. + /// The expected number of calls. + /// The actual number calls. + public HttpCallAssertException(IList conditions, int? expectedCalls, int actualCalls) : base(BuildMessage(conditions, expectedCalls, actualCalls)) { } - private static string BuildMessage(string urlPattern, int? expectedCalls, int actualCalls) { - if (expectedCalls == null) - return $"Expected call to {urlPattern} was not made."; - - return new StringBuilder() - .Append("Expected ").Append(expectedCalls.Value) - .Append(expectedCalls == 1 ? " call" : " calls") - .Append(" to ").Append(urlPattern).Append(" but ").Append(actualCalls) - .Append(actualCalls == 1 ? " call was made." : " calls were made.") - .ToString(); + private static string BuildMessage(IList conditions, int? expectedCalls, int actualCalls) { + var expected = + (expectedCalls == null) ? "any calls to be made" : + (expectedCalls == 0) ? "no calls to be made" : + (expectedCalls == 1) ? "1 call to be made" : + expectedCalls + " calls to be made"; + var actual = + (actualCalls == 0) ? "no matching calls were made" : + (actualCalls == 1) ? "1 matching call was made" : + actualCalls + " matching calls were made"; + if (conditions.Any()) + expected += " with " + string.Join(" and ", conditions); + else + actual = actual.Replace(" matching", ""); + return $"Expected {expected}, but {actual}."; } } } \ No newline at end of file diff --git a/src/Flurl.Http.Shared/Testing/HttpCallAssertion.cs b/src/Flurl.Http.Shared/Testing/HttpCallAssertion.cs index 6a94e64d..eac88357 100644 --- a/src/Flurl.Http.Shared/Testing/HttpCallAssertion.cs +++ b/src/Flurl.Http.Shared/Testing/HttpCallAssertion.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -16,9 +17,9 @@ namespace Flurl.Http.Testing public class HttpCallAssertion { private readonly bool _negate; + private readonly IList _expectedConditions = new List(); private IList _calls; - private string _urlPattern; /// Set of calls (usually from HttpTest.CallLog) to assert against. /// if true, assertions pass when calls matching criteria were NOT made. @@ -45,7 +46,11 @@ public void Times(int expectedCount) { /// /// Can contain * wildcard. public HttpCallAssertion WithUrlPattern(string urlPattern) { - _urlPattern = urlPattern; // this will land in the exception message when we assert, which is the only reason for capturing it + if (urlPattern == "*") { + Assert(); + return this; + } + _expectedConditions.Add($"URL pattern {urlPattern}"); return With(c => MatchesPattern(c.Url, urlPattern)); } @@ -55,31 +60,102 @@ public HttpCallAssertion WithUrlPattern(string urlPattern) { /// The query parameter name. /// public HttpCallAssertion WithQueryParam(string name) { - return With(c => new Url(c.Url).QueryParams.Any(q => q.Name == name)); + _expectedConditions.Add($"query parameter {name}"); + return With(c => c.Url.QueryParams.Any(q => q.Name == name)); } /// - /// Asserts whether calls were made containing the given query parameter name/value. + /// Asserts whether calls were made NOT containing the given query parameter. + /// + /// The query parameter name. + /// + public HttpCallAssertion WithoutQueryParam(string name) { + _expectedConditions.Add($"no query parameter {name}"); + return Without(c => c.Url.QueryParams.Any(q => q.Name == name)); + } + + /// + /// Asserts whether calls were made containing all the given query parameters (regardless of their values). + /// + /// The query parameter names. + /// + public HttpCallAssertion WithQueryParams(params string[] names) { + if (!names.Any()) { + _expectedConditions.Add("any query parameters"); + return With(c => c.Url.QueryParams.Any()); + } + return names.Select(WithQueryParam).LastOrDefault() ?? this; + } + + /// + /// Asserts whether calls were made NOT containing ANY of the given query parameters. + /// + /// The query parameter names. + /// + public HttpCallAssertion WithoutQueryParams(params string[] names) { + if (!names.Any()) { + _expectedConditions.Add("no query parameters"); + return Without(c => c.Url.QueryParams.Any()); + } + return names.Select(WithoutQueryParam).LastOrDefault() ?? this; + } + + /// + /// Asserts whether calls were made containing the given query parameter name and value. + /// + /// The query parameter name. + /// The query parameter value. Can contain * wildcard. + /// + public HttpCallAssertion WithQueryParamValue(string name, object value) { + if (value is IEnumerable && !(value is string)) { + foreach (var val in (IEnumerable)value) + WithQueryParamValue(name, val); + return this; + } + _expectedConditions.Add($"query parameter {name}={value}"); + return With(c => c.Url.QueryParams.Any(qp => QueryParamMatches(qp, name, value))); + } + + /// + /// Asserts whether calls were made NOT containing the given query parameter name and value. /// /// The query parameter name. /// The query parameter value. Can contain * wildcard. /// - public HttpCallAssertion WithQueryParam(string name, object value) { - return With(c => new Url(c.Url).QueryParams.Any(q => q.Name == name && MatchesPattern(q.Value.ToString(), value.ToString()))); + public HttpCallAssertion WithoutQueryParamValue(string name, object value) { + if (value is IEnumerable && !(value is string)) { + foreach (var val in (IEnumerable)value) + WithoutQueryParamValue(name, val); + return this; + } + _expectedConditions.Add($"no query parameter {name}={value}"); + return Without(c => c.Url.QueryParams.Any(qp => QueryParamMatches(qp, name, value))); } /// - /// Asserts whether calls were made containing all of the given query parameters. + /// Asserts whether calls were made containing all of the given query parameter values. /// /// Object (usually anonymous) or dictionary that is parsed to name/value query parameters to check for. /// - public HttpCallAssertion WithQueryParams(object values) { - return With(c => { - var expected = values.ToKeyValuePairs().Select(kv => $"{kv.Key}={kv.Value}"); - var actual = new Url(c.Url).QueryParams.Select(q => $"{q.Name}={q.Value}"); - //http://stackoverflow.com/a/333034/62600 - return !expected.Except(actual).Any(); - }); + public HttpCallAssertion WithQueryParamValues(object values) { + return values.ToKeyValuePairs().Select(kv => WithQueryParamValue(kv.Key, kv.Value)).LastOrDefault() ?? this; + } + + /// + /// Asserts whether calls were made NOT containing ANY of the given query parameter values. + /// + /// Object (usually anonymous) or dictionary that is parsed to name/value query parameters to check for. + /// + public HttpCallAssertion WithoutQueryParamValues(object values) { + return values.ToKeyValuePairs().Select(kv => WithoutQueryParamValue(kv.Key, kv.Value)).LastOrDefault() ?? this; + } + + private bool QueryParamMatches(QueryParameter qp, string name, object value) { + if (qp.Name != name) + return false; + if (value is string) + return MatchesPattern(qp.Value?.ToString(), value?.ToString()); + return qp.Value?.ToString() == value?.ToString(); } /// @@ -87,6 +163,7 @@ public HttpCallAssertion WithQueryParams(object values) { /// /// Can contain * wildcard. public HttpCallAssertion WithRequestBody(string bodyPattern) { + _expectedConditions.Add($"body matching pattern {bodyPattern}"); return With(c => MatchesPattern(c.RequestBody, bodyPattern)); } @@ -103,6 +180,7 @@ public HttpCallAssertion WithRequestJson(object body) { /// Asserts whether calls were made with given HTTP verb. /// public HttpCallAssertion WithVerb(HttpMethod httpMethod) { + _expectedConditions.Add("verb " + httpMethod); return With(c => c.Request.Method == httpMethod); } @@ -110,6 +188,7 @@ public HttpCallAssertion WithVerb(HttpMethod httpMethod) { /// Asserts whether calls were made with a request body of the given content (MIME) type. /// public HttpCallAssertion WithContentType(string mediaType) { + _expectedConditions.Add("content type " + mediaType); return With(c => c.Request.Content.Headers.ContentType.MediaType == mediaType); } @@ -118,8 +197,8 @@ public HttpCallAssertion WithContentType(string mediaType) { /// /// Expected token value /// - public HttpCallAssertion WithOAuthBearerToken(string token) - { + public HttpCallAssertion WithOAuthBearerToken(string token) { + _expectedConditions.Add("OAuth bearer token " + token); return With(c => c.Request.Headers.Authorization?.Scheme == "Bearer" && c.Request.Headers.Authorization?.Parameter == token); } @@ -130,15 +209,15 @@ public HttpCallAssertion WithOAuthBearerToken(string token) /// Expected username /// Expected password /// - public HttpCallAssertion WithBasicAuth(string username, string password) - { + public HttpCallAssertion WithBasicAuth(string username, string password) { + _expectedConditions.Add($"basic auth credentials {username}/{password}"); var value = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); return With(c => c.Request.Headers.Authorization?.Scheme == "Basic" && c.Request.Headers.Authorization?.Parameter == value); } /// - /// Asserts whether calls were made matching a given predicate function. + /// Asserts whether calls were made matching the given predicate function. /// /// Predicate (usually a lambda expression) that tests an HttpCall and returns a bool. public HttpCallAssertion With(Func match) { @@ -147,12 +226,22 @@ public HttpCallAssertion With(Func match) { return this; } + /// + /// Asserts whether calls were made that do NOT match the given predicate function. + /// + /// Predicate (usually a lambda expression) that tests an HttpCall and returns a bool. + public HttpCallAssertion Without(Func match) { + _calls = _calls.Where(c => !match(c)).ToList(); + Assert(); + return this; + } + private void Assert(int? count = null) { var pass = count.HasValue ? (_calls.Count == count.Value) : _calls.Any(); if (_negate) pass = !pass; if (!pass) - throw new HttpCallAssertException(_urlPattern, count, _calls.Count); + throw new HttpCallAssertException(_expectedConditions, count, _calls.Count); } private bool MatchesPattern(string textToCheck, string pattern) { diff --git a/src/Flurl.Http.Shared/Testing/HttpTest.cs b/src/Flurl.Http.Shared/Testing/HttpTest.cs index 25e93ebe..d5c51f61 100644 --- a/src/Flurl.Http.Shared/Testing/HttpTest.cs +++ b/src/Flurl.Http.Shared/Testing/HttpTest.cs @@ -109,7 +109,7 @@ internal HttpResponseMessage GetNextResponse() { public List CallLog { get; private set; } /// - /// Throws an HttpCallAssertException if a URL matching the given pattern was not called. + /// Asserts whether matching URL was called, throwing HttpCallAssertException if it wasn't. /// /// URL that should have been called. Can include * wildcard character. public HttpCallAssertion ShouldHaveCalled(string urlPattern) { @@ -117,13 +117,27 @@ public HttpCallAssertion ShouldHaveCalled(string urlPattern) { } /// - /// Throws an HttpCallAssertException if a URL matching the given pattern was called. + /// Asserts whether matching URL was NOT called, throwing HttpCallAssertException if it was. /// /// URL that should not have been called. Can include * wildcard character. public void ShouldNotHaveCalled(string urlPattern) { new HttpCallAssertion(CallLog, true).WithUrlPattern(urlPattern); } + /// + /// Asserts whether any HTTP call was made, throwing HttpCallAssertException if none were. + /// + public HttpCallAssertion ShouldHaveMadeACall() { + return new HttpCallAssertion(CallLog).WithUrlPattern("*"); + } + + /// + /// Asserts whether no HTTP calls were made, throwing HttpCallAssertException if any were. + /// + public void ShouldNotHaveMadeACall() { + new HttpCallAssertion(CallLog, true).WithUrlPattern("*"); + } + /// /// Releases unmanaged and - optionally - managed resources. /// diff --git a/src/Flurl.Http/project.json b/src/Flurl.Http/project.json index 650dee28..6692a1da 100644 --- a/src/Flurl.Http/project.json +++ b/src/Flurl.Http/project.json @@ -1,6 +1,6 @@ { "title": "Flurl.Http", - "version": "1.1.0-pre", + "version": "1.1.1-pre", "dependencies": { "Flurl": "2.2.1",