diff --git a/Changelog.txt b/Changelog.txt index 3f411df19..c77de4eb8 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,6 @@ +11.2.1 - 28 August 2022 +Fix test helper .Only() not taking into account parent-level unmatched failures (#1986) + 11.2.0 - 8 August 2022 Resolve issue with unexpected results when with nested Include calls with the MemberNameValidatorSelector (#1989) Add a new Selector Factory for the Composite Selector (#1988) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 61c7b67c3..d39d0295c 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 11.2.0 + 11.2.1 diff --git a/src/FluentValidation.Tests/TestExtensions.cs b/src/FluentValidation.Tests/TestExtensions.cs index 0812fb90b..6aade04e4 100644 --- a/src/FluentValidation.Tests/TestExtensions.cs +++ b/src/FluentValidation.Tests/TestExtensions.cs @@ -24,7 +24,7 @@ namespace FluentValidation.Tests; //http://code.google.com/p/specunit-net/source/browse/trunk/src/SpecUnit/SpecificationExtensions.cs public static class TestExtensions { public static void ShouldEqual(this object actual, object expected) { - Assert.Equal(expected, actual); + Assert.Equal(expected, actual); } public static void ShouldBeTheSameAs(this object actual, object expected) { diff --git a/src/FluentValidation.Tests/ValidatorTesterTester.cs b/src/FluentValidation.Tests/ValidatorTesterTester.cs index 40f8a2ea6..6e85b3c2b 100644 --- a/src/FluentValidation.Tests/ValidatorTesterTester.cs +++ b/src/FluentValidation.Tests/ValidatorTesterTester.cs @@ -873,6 +873,21 @@ public void ShouldHaveValidationErrorFor_WithMessage_Only() { .Only(); } + [Fact] + public void ShouldHaveValidationErrorFor_WithMessage_Only_throws_when_there_is_a_failure_for_a_different_property() { + var validator = new InlineValidator(); + validator.RuleFor(x => x.Surname) + .Must((x, ct) => false); + validator.RuleFor(x => x.Forename).NotEmpty(); + + Assert.Throws(() => + validator.TestValidate(new Person()) + .ShouldHaveValidationErrorFor(x => x.Surname) + .WithErrorMessage("The specified condition was not met for 'Surname'.") + .Only() + ).Message.ShouldEqual("Expected to have errors only matching specified conditions\n----\nUnexpected Errors:\n[0]: 'Forename' must not be empty.\n"); + } + [Fact] public void ShouldHaveValidationErrorFor_WithSeverity_Only() { var validator = new InlineValidator(); @@ -950,6 +965,51 @@ public void ShouldHaveValidationErrorFor_WithPropertyName_Only_throws() { ).Message.ShouldEqual("Expected to have errors only matching specified conditions\n----\nUnexpected Errors:\n[0]: 'Now' must be less than '1/1/1900 12:00:00 AM'.\n"); } + [Fact] + public void ShouldHaveValidationErrorFor_WithMessage_Only_throws_combining_several_conditions() { + var validator = new InlineValidator(); + + // 2 rules with same property and message, different error code. + validator.RuleFor(x => x.Surname) + .Must((x, ct) => false); + + validator.RuleFor(x => x.Surname) + .Must((x, ct) => false) + .WithErrorCode("Foo"); + + Assert.Throws(() => + validator.TestValidate(new Person()) + .ShouldHaveValidationErrorFor(x => x.Surname) + .WithErrorMessage("The specified condition was not met for 'Surname'.") + .WithErrorCode("Foo") + .Only() + ).Message.ShouldEqual("Expected to have errors only matching specified conditions\n----\nUnexpected Errors:\n[0]: The specified condition was not met for 'Surname'.\n"); + } + + [Fact] + public void ShouldHaveValidationErrorFor_WithMessage_Only_throws_combining_several_conditions_and_another_property() { + var validator = new InlineValidator(); + + // 2 rules with same property and message, different error code. + validator.RuleFor(x => x.Surname) + .Must((x, ct) => false); + + validator.RuleFor(x => x.Surname) + .Must((x, ct) => false) + .WithErrorCode("Foo"); + + // Another message for a different property. + validator.RuleFor(x => x.Forename).NotEmpty(); + + Assert.Throws(() => + validator.TestValidate(new Person()) + .ShouldHaveValidationErrorFor(x => x.Surname) + .WithErrorMessage("The specified condition was not met for 'Surname'.") + .WithErrorCode("Foo") + .Only() + ).Message.ShouldEqual("Expected to have errors only matching specified conditions\n----\nUnexpected Errors:\n[0]: The specified condition was not met for 'Surname'.\n[1]: 'Forename' must not be empty.\n"); + } + private class AddressValidator : AbstractValidator
{ } diff --git a/src/FluentValidation/TestHelper/ITestValidationContinuation.cs b/src/FluentValidation/TestHelper/ITestValidationContinuation.cs index 2a6f21243..a3f0a02f7 100644 --- a/src/FluentValidation/TestHelper/ITestValidationContinuation.cs +++ b/src/FluentValidation/TestHelper/ITestValidationContinuation.cs @@ -11,27 +11,19 @@ public interface ITestValidationWith : ITestValidationContinuation { public interface ITestValidationContinuation : IEnumerable { IEnumerable UnmatchedFailures { get; } + //TODO: 12.x expose MatchedFailures on the interface too. } internal class TestValidationContinuation : ITestValidationContinuation, ITestValidationWith { private readonly IEnumerable _allFailures; private readonly List> _predicates; - public static TestValidationContinuation Create(IEnumerable failures) => - new TestValidationContinuation(failures); + public ITestValidationContinuation Parent { get; } - public static TestValidationContinuation Create(ITestValidationContinuation continuation) { - if (continuation is TestValidationContinuation instance) - return instance; - var allFailures = continuation.Union(continuation.UnmatchedFailures); - instance = new TestValidationContinuation(allFailures); - instance.ApplyPredicate(failure => !continuation.UnmatchedFailures.Contains(failure)); - return instance; - } - - private TestValidationContinuation(IEnumerable failures) { + public TestValidationContinuation(IEnumerable failures, ITestValidationContinuation parent = null) { _allFailures = failures; _predicates = new List>(); + Parent = parent; } public void ApplyPredicate(Func failurePredicate) { diff --git a/src/FluentValidation/TestHelper/TestValidationResult.cs b/src/FluentValidation/TestHelper/TestValidationResult.cs index e4d9b93a3..884e4a15e 100644 --- a/src/FluentValidation/TestHelper/TestValidationResult.cs +++ b/src/FluentValidation/TestHelper/TestValidationResult.cs @@ -21,7 +21,10 @@ namespace FluentValidation.TestHelper; using System; +using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; +using System.Text.RegularExpressions; using Internal; using Results; @@ -33,19 +36,74 @@ public TestValidationResult(ValidationResult validationResult) : base(validation public ITestValidationWith ShouldHaveValidationErrorFor(Expression> memberAccessor) { string propertyName = ValidatorOptions.Global.PropertyNameResolver(typeof(T), memberAccessor.GetMember(), memberAccessor); - return ValidationTestExtension.ShouldHaveValidationError(Errors, propertyName, true); + return ShouldHaveValidationError(propertyName, true); } public void ShouldNotHaveValidationErrorFor(Expression> memberAccessor) { string propertyName = ValidatorOptions.Global.PropertyNameResolver(typeof(T), memberAccessor.GetMember(), memberAccessor); - ValidationTestExtension.ShouldNotHaveValidationError(Errors, propertyName, true); + ShouldNotHaveValidationError(propertyName, true); } public ITestValidationWith ShouldHaveValidationErrorFor(string propertyName) { - return ValidationTestExtension.ShouldHaveValidationError(Errors, propertyName, false); + return ShouldHaveValidationError(propertyName, false); } public void ShouldNotHaveValidationErrorFor(string propertyName) { - ValidationTestExtension.ShouldNotHaveValidationError(Errors, propertyName, false); + ShouldNotHaveValidationError(propertyName, false); + } + + // TODO: Make private in 12.0 + internal ITestValidationWith ShouldHaveValidationError(string propertyName, bool shouldNormalizePropertyName) { + var result = new TestValidationContinuation(Errors); + result.ApplyPredicate(x => (shouldNormalizePropertyName ? NormalizePropertyName(x.PropertyName) == propertyName : x.PropertyName == propertyName) + || (string.IsNullOrEmpty(x.PropertyName) && string.IsNullOrEmpty(propertyName)) + || propertyName == ValidationTestExtension.MatchAnyFailure); + + if (result.Any()) { + return result; + } + + // We expected an error but failed to match it. + var errorMessageBanner = $"Expected a validation error for property {propertyName}"; + + string errorMessage = ""; + + if (Errors?.Any() == true) { + string errorMessageDetails = ""; + for (int i = 0; i < Errors.Count; i++) { + errorMessageDetails += $"[{i}]: {Errors[i].PropertyName}\n"; + } + errorMessage = $"{errorMessageBanner}\n----\nProperties with Validation Errors:\n{errorMessageDetails}"; + } + else { + errorMessage = $"{errorMessageBanner}"; + } + + throw new ValidationTestException(errorMessage); + } + + // TODO: Make private in 12.0 + internal void ShouldNotHaveValidationError(string propertyName, bool shouldNormalizePropertyName) { + var failures = Errors.Where(x => (shouldNormalizePropertyName ? NormalizePropertyName(x.PropertyName) == propertyName : x.PropertyName == propertyName) + || (string.IsNullOrEmpty(x.PropertyName) && string.IsNullOrEmpty(propertyName)) + || propertyName == ValidationTestExtension.MatchAnyFailure + ).ToList(); + + if (failures.Any()) { + var errorMessageBanner = $"Expected no validation errors for property {propertyName}"; + if (propertyName == ValidationTestExtension.MatchAnyFailure) { + errorMessageBanner = "Expected no validation errors"; + } + string errorMessageDetails = ""; + for (int i = 0; i < failures.Count; i++) { + errorMessageDetails += $"[{i}]: {failures[i].ErrorMessage}\n"; + } + var errorMessage = $"{errorMessageBanner}\n----\nValidation Errors:\n{errorMessageDetails}"; + throw new ValidationTestException(errorMessage, failures); + } + } + + private static string NormalizePropertyName(string propertyName) { + return Regex.Replace(propertyName, @"\[.*\]", string.Empty); } } diff --git a/src/FluentValidation/TestHelper/ValidatorTestExtensions.cs b/src/FluentValidation/TestHelper/ValidatorTestExtensions.cs index aa01e604e..27abeca07 100644 --- a/src/FluentValidation/TestHelper/ValidatorTestExtensions.cs +++ b/src/FluentValidation/TestHelper/ValidatorTestExtensions.cs @@ -105,15 +105,17 @@ public static async Task> TestValidateAsync(this IVal return new TestValidationResult(validationResult); } + // TODO: 12.0: Move this to an instance method on TestValidationResult public static ITestValidationContinuation ShouldHaveAnyValidationError(this TestValidationResult testValidationResult) { if (!testValidationResult.Errors.Any()) throw new ValidationTestException($"Expected at least one validation error, but none were found."); - return TestValidationContinuation.Create(testValidationResult.Errors); + return new TestValidationContinuation(testValidationResult.Errors); } + // TODO: 12.0: Move this to an instance method on TestValidationResult public static void ShouldNotHaveAnyValidationErrors(this TestValidationResult testValidationResult) { - ShouldNotHaveValidationError(testValidationResult.Errors, MatchAnyFailure, true); + testValidationResult.ShouldNotHaveValidationError(MatchAnyFailure, true); } private static string BuildErrorMessage(ValidationFailure failure, string exceptionMessage, string defaultMessage) { @@ -134,57 +136,10 @@ private static string BuildErrorMessage(ValidationFailure failure, string except return defaultMessage; } - internal static ITestValidationWith ShouldHaveValidationError(IList errors, string propertyName, bool shouldNormalizePropertyName) { - var result = TestValidationContinuation.Create(errors); - result.ApplyPredicate(x => (shouldNormalizePropertyName ? NormalizePropertyName(x.PropertyName) == propertyName : x.PropertyName == propertyName) - || (string.IsNullOrEmpty(x.PropertyName) && string.IsNullOrEmpty(propertyName)) - || propertyName == MatchAnyFailure); - - if (result.Any()) { - return result; - } - - // We expected an error but failed to match it. - var errorMessageBanner = $"Expected a validation error for property {propertyName}"; - - string errorMessage = ""; - - if (errors?.Any() == true) { - string errorMessageDetails = ""; - for (int i = 0; i < errors.Count; i++) { - errorMessageDetails += $"[{i}]: {errors[i].PropertyName}\n"; - } - errorMessage = $"{errorMessageBanner}\n----\nProperties with Validation Errors:\n{errorMessageDetails}"; - } - else { - errorMessage = $"{errorMessageBanner}"; - } - - throw new ValidationTestException(errorMessage); - } - - internal static void ShouldNotHaveValidationError(IEnumerable errors, string propertyName, bool shouldNormalizePropertyName) { - var failures = errors.Where(x => (shouldNormalizePropertyName ? NormalizePropertyName(x.PropertyName) == propertyName : x.PropertyName == propertyName) - || (string.IsNullOrEmpty(x.PropertyName) && string.IsNullOrEmpty(propertyName)) - || propertyName == MatchAnyFailure - ).ToList(); - - if (failures.Any()) { - var errorMessageBanner = $"Expected no validation errors for property {propertyName}"; - if (propertyName == MatchAnyFailure) { - errorMessageBanner = "Expected no validation errors"; - } - string errorMessageDetails = ""; - for (int i = 0; i < failures.Count; i++) { - errorMessageDetails += $"[{i}]: {failures[i].ErrorMessage}\n"; - } - var errorMessage = $"{errorMessageBanner}\n----\nValidation Errors:\n{errorMessageDetails}"; - throw new ValidationTestException(errorMessage, failures); - } - } public static ITestValidationWith When(this ITestValidationContinuation failures, Func failurePredicate, string exceptionMessage = null) { - var result = TestValidationContinuation.Create(((TestValidationContinuation)failures).MatchedFailures); + //TODO 12.0 remove casts. + var result = new TestValidationContinuation(((TestValidationContinuation)failures).MatchedFailures, failures); result.ApplyPredicate(failurePredicate); var anyMatched = result.Any(); @@ -198,7 +153,8 @@ public static ITestValidationWith When(this ITestValidationContinuation failures } public static ITestValidationContinuation WhenAll(this ITestValidationContinuation failures, Func failurePredicate, string exceptionMessage = null) { - var result = TestValidationContinuation.Create(((TestValidationContinuation)failures).MatchedFailures); + //TODO 12.0 remove casts. + var result = new TestValidationContinuation(((TestValidationContinuation)failures).MatchedFailures, failures); result.ApplyPredicate(failurePredicate); bool allMatched = !result.UnmatchedFailures.Any(); @@ -250,13 +206,24 @@ public static ITestValidationContinuation WithoutErrorCode(this ITestValidationC } public static ITestValidationWith Only(this ITestValidationWith failures) { - if (failures.UnmatchedFailures.Any()) { + var unmatchedFailures = failures.UnmatchedFailures; + var continuation = (TestValidationContinuation) failures; + // Also add in any unmatched failures from the parent (if there is one) recursively. + do { + if (continuation.Parent != null) { + unmatchedFailures = unmatchedFailures.Union(continuation.Parent.UnmatchedFailures); + } + continuation = continuation.Parent as TestValidationContinuation; + } while (continuation != null); + + var unmatchedFailuresList = unmatchedFailures.ToList(); + + if (unmatchedFailuresList.Count > 0) { var errorMessageBanner = "Expected to have errors only matching specified conditions"; string errorMessageDetails = ""; - var unmatchedFailures = failures.UnmatchedFailures.ToList(); - for (int i = 0; i < unmatchedFailures.Count; i++) { - errorMessageDetails += $"[{i}]: {unmatchedFailures[i].ErrorMessage}\n"; + for (int i = 0; i < unmatchedFailuresList.Count; i++) { + errorMessageDetails += $"[{i}]: {unmatchedFailuresList[i].ErrorMessage}\n"; } var errorMessage = $"{errorMessageBanner}\n----\nUnexpected Errors:\n{errorMessageDetails}"; @@ -264,8 +231,4 @@ public static ITestValidationWith Only(this ITestValidationWith failures) { } return failures; } - - private static string NormalizePropertyName(string propertyName) { - return Regex.Replace(propertyName, @"\[.*\]", string.Empty); - } }