diff --git a/Src/FluentAssertions/Primitives/StringAssertions.cs b/Src/FluentAssertions/Primitives/StringAssertions.cs index 83ff92ec69..a55ad0236d 100644 --- a/Src/FluentAssertions/Primitives/StringAssertions.cs +++ b/Src/FluentAssertions/Primitives/StringAssertions.cs @@ -387,6 +387,46 @@ public AndConstraint NotMatchEquivalentOf(string wildcardPattern, s return new AndConstraint((TAssertions)this); } + /// + /// Asserts that a string matches a regular expression with expected occurrence + /// + /// + /// The regular expression with which the subject is matched. + /// + /// + /// A constraint specifying the expected amount of times a regex should match a string. + /// It can be created by invoking static methods Once, Twice, Thrice, or Times(int) + /// on the classes , , , , and . + /// For example, or . + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndConstraint MatchRegex([RegexPattern][StringSyntax("Regex")] string regularExpression, + OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) + { + Guard.ThrowIfArgumentIsNull(regularExpression, nameof(regularExpression), + "Cannot match string against . Provide a regex pattern or use the BeNull method."); + + Regex regex; + try + { + regex = new Regex(regularExpression); + } + catch (ArgumentException) + { + Execute.Assertion.FailWith("Cannot match {context:string} against {0} because it is not a valid regular expression.", + regularExpression); + return new AndConstraint((TAssertions)this); + } + + return MatchRegex(regex, occurrenceConstraint, because, becauseArgs); + } + /// /// Asserts that a string matches a regular expression. /// @@ -420,6 +460,60 @@ public AndConstraint MatchRegex([RegexPattern][StringSyntax("Regex" return MatchRegex(regex, because, becauseArgs); } + /// + /// Asserts that a string matches a regular expression with expected occurrence + /// + /// + /// The regular expression with which the subject is matched. + /// + /// + /// A constraint specifying the expected amount of times a regex should match a string. + /// It can be created by invoking static methods Once, Twice, Thrice, or Times(int) + /// on the classes , , , , and . + /// For example, or . + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndConstraint MatchRegex(Regex regularExpression, + OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) + { + Guard.ThrowIfArgumentIsNull(regularExpression, nameof(regularExpression), + "Cannot match string against . Provide a regex pattern or use the BeNull method."); + + var regexStr = regularExpression.ToString(); + if (regexStr.Length == 0) + { + throw new ArgumentException( + "Cannot match string against an empty string. Provide a regex pattern or use the BeEmpty method.", + nameof(regularExpression)); + } + + bool success = Execute.Assertion + .ForCondition(Subject is not null) + .UsingLineBreaks + .BecauseOf(because, becauseArgs) + .FailWith("Expected {context:string} to match regex {0}{reason}, but it was .", regexStr); + + if (success) + { + int actual = regularExpression.Matches(Subject).Count; + + Execute.Assertion + .ForConstraint(occurrenceConstraint, actual) + .UsingLineBreaks + .BecauseOf(because, becauseArgs) + .FailWith($"Expected {{context:string}} to match regex {{0}} {{expectedOccurrence}}{{reason}}, but found it {actual.Times()}.", + regexStr); + } + + return new AndConstraint((TAssertions)this); + } + /// /// Asserts that a string matches a regular expression. /// @@ -433,7 +527,8 @@ public AndConstraint MatchRegex([RegexPattern][StringSyntax("Regex" /// /// Zero or more objects to format using the placeholders in . /// - public AndConstraint MatchRegex(Regex regularExpression, string because = "", params object[] becauseArgs) + public AndConstraint MatchRegex(Regex regularExpression, + string because = "", params object[] becauseArgs) { Guard.ThrowIfArgumentIsNull(regularExpression, nameof(regularExpression), "Cannot match string against . Provide a regex pattern or use the BeNull method."); diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 912fd0aceb..6ff5858bab 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -2121,6 +2121,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index c3a7569a06..cf6defd2c8 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -2204,6 +2204,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt index 75a3c8742d..08f866a950 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt @@ -2121,6 +2121,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt index 675ecb37c5..fe3a364b04 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt @@ -2121,6 +2121,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index 04083f6f7d..e210dc45ac 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -2073,6 +2073,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index d361335c79..e0c14517ca 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -2121,6 +2121,8 @@ namespace FluentAssertions.Primitives public FluentAssertions.AndConstraint MatchEquivalentOf(string wildcardPattern, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(string regularExpression, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(string regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } + public FluentAssertions.AndConstraint MatchRegex(System.Text.RegularExpressions.Regex regularExpression, FluentAssertions.OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBe(string unexpected, string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEmpty(string because = "", params object[] becauseArgs) { } public FluentAssertions.AndConstraint NotBeEquivalentTo(string unexpected, string because = "", params object[] becauseArgs) { } diff --git a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.MatchRegex.cs b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.MatchRegex.cs index 5fd57a9c32..3ac1cb5705 100644 --- a/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.MatchRegex.cs +++ b/Tests/FluentAssertions.Specs/Primitives/StringAssertionSpecs.MatchRegex.cs @@ -199,6 +199,159 @@ public void When_a_string_is_matched_against_an_empty_regex_it_should_throw_with .WithParameterName("regularExpression"); } + [Fact] + public void When_a_string_is_matched_and_the_count_of_matches_fits_into_the_expected_it_passes() + { + // Arrange + string subject = "hello world"; + + // Act + Action act = () => subject.Should().MatchRegex(new Regex("hello.*"), AtLeast.Once()); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_a_string_is_matched_and_the_count_of_matches_do_not_fit_the_expected_it_fails() + { + // Arrange + string subject = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt " + + "ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et " + + "ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet."; + + // Act + Action act = () => subject.Should().MatchRegex("Lorem.*", Exactly.Twice()); + + // Assert + act.Should().Throw() + .WithMessage($"Expected subject to match regex*\"Lorem.*\" exactly 2 times, but found it 1 time."); + } + + [Fact] + public void When_a_string_is_matched_and_the_expected_count_is_zero_and_string_not_matches_it_passes() + { + // Arrange + string subject = "a"; + + // Act + Action act = () => subject.Should().MatchRegex("b", Exactly.Times(0)); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_a_string_is_matched_and_the_expected_count_is_zero_and_string_matches_it_fails() + { + // Arrange + string subject = "a"; + + // Act + Action act = () => subject.Should().MatchRegex("a", Exactly.Times(0)); + + // Assert + act.Should().Throw() + .WithMessage($"Expected subject to match regex*\"a\" exactly 0 times, but found it 1 time."); + } + + [Fact] + public void When_the_subject_is_null_it_fails() + { + // Arrange + string subject = null; + + // Act + Action act = () => + { + using var _ = new AssertionScope(); + subject.Should().MatchRegex(".*", Exactly.Times(0), "because it should be a string"); + }; + + // Assert + act.Should().ThrowExactly() + .WithMessage("Expected subject to match regex*\".*\" because it should be a string, but it was ."); + } + + [Fact] + public void When_the_subject_is_empty_and_expected_count_is_zero_it_passes() + { + // Arrange + string subject = string.Empty; + + // Act + Action act = () => + { + using var _ = new AssertionScope(); + subject.Should().MatchRegex("a", Exactly.Times(0)); + }; + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void When_the_subject_is_empty_and_expected_count_is_more_than_zero_it_fails() + { + // Arrange + string subject = string.Empty; + + // Act + Action act = () => + { + using var _ = new AssertionScope(); + subject.Should().MatchRegex(".+", AtLeast.Once()); + }; + + // Assert + act.Should().Throw() + .WithMessage($"Expected subject to match regex* at least 1 time, but found it 0 times.*"); + } + + [Fact] + public void When_regex_is_null_it_fails_and_ignores_occurrences() + { + // Arrange + string subject = "a"; + + // Act + Action act = () => subject.Should().MatchRegex((Regex)null, Exactly.Times(0)); + + // Assert + act.Should().ThrowExactly() + .WithMessage("Cannot match string against . Provide a regex pattern or use the BeNull method.*") + .WithParameterName("regularExpression"); + } + + [Fact] + public void When_regex_is_empty_it_fails_and_ignores_occurrences() + { + // Arrange + string subject = "a"; + + // Act + Action act = () => subject.Should().MatchRegex(string.Empty, Exactly.Times(0)); + + // Assert + act.Should().ThrowExactly() + .WithMessage("Cannot match string against an empty string. Provide a regex pattern or use the BeEmpty method.*") + .WithParameterName("regularExpression"); + } + + [Fact] + public void When_regex_is_invalid_it_fails_and_ignores_occurrences() + { + // Arrange + string subject = "a"; + + // Act + Action act = () => subject.Should().MatchRegex(".**", Exactly.Times(0)); + + // Assert + act.Should().ThrowExactly() + .WithMessage("Cannot match subject against \".**\" because it is not a valid regular expression.*"); + } + #endregion #region Not Match Regex diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 1736f5f52f..441973054d 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -14,6 +14,7 @@ sidebar: * Added support for .NET6 `DateOnly` struct - [#1844](https://github.com/fluentassertions/fluentassertions/pull/1844) * Added support for .NET6 `TimeOnly` struct - [#1848](https://github.com/fluentassertions/fluentassertions/pull/1848) * Added `NotBe` for nullable boolean values - [#1865](https://github.com/fluentassertions/fluentassertions/pull/1865) +* Added a new overload to `MatchRegex()` to assert on the number of regex matches - [#1869](https://github.com/fluentassertions/fluentassertions/pull/1869) ### Fixes * `EnumAssertions.Be` did not determine the caller name - [#1835](https://github.com/fluentassertions/fluentassertions/pull/1835) diff --git a/docs/_pages/strings.md b/docs/_pages/strings.md index aae2b12148..8fd3ec6c4e 100644 --- a/docs/_pages/strings.md +++ b/docs/_pages/strings.md @@ -104,7 +104,14 @@ And if wildcards aren't enough for you, you can always use some regular expressi ```csharp someString.Should().MatchRegex("h.*\\sworld.$"); -someString.Should().MatchRegex(new System.Text.RegularExpressions.Regex("h.*\\sworld.$")); -subject.Should().NotMatchRegex(new System.Text.RegularExpressions.Regex(".*earth.*")); +someString.Should().MatchRegex(new Regex("h.*\\sworld.$")); +subject.Should().NotMatchRegex(new Regex(".*earth.*")); subject.Should().NotMatchRegex(".*earth.*"); ``` + +And if that's not enough, you can assert on the number of matches of a regular expression: + +```csharp +someString.Should().MatchRegex("h.*\\sworld.$", Exactly.Once()); +someString.Should().MatchRegex(new Regex("h.*\\sworld.$"), AtLeast.Twice()); +```