Add AssertJ support for the Model

This commit adds AssertJ compatible assertions for the Model that is
generated from an HTTP request.

See gh-21178
This commit is contained in:
Stéphane Nicoll
2024-03-15 13:26:53 +01:00
parent b46e528922
commit 1cdbcc58f3
5 changed files with 607 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.validation;
import java.util.List;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.AssertProvider;
import org.assertj.core.api.Assertions;
import org.assertj.core.api.ListAssert;
import org.assertj.core.error.BasicErrorMessageFactory;
import org.assertj.core.internal.Failures;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import static org.assertj.core.api.Assertions.assertThat;
/**
* AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to
* {@link BindingResult}.
*
* @author Stephane Nicoll
* @since 6.2
* @param <SELF> the type of assertions
*/
public abstract class AbstractBindingResultAssert<SELF extends AbstractBindingResultAssert<SELF>> extends AbstractAssert<SELF, BindingResult> {
private final Failures failures = Failures.instance();
private final String name;
protected AbstractBindingResultAssert(String name, BindingResult bindingResult, Class<?> selfType) {
super(bindingResult, selfType);
this.name = name;
as("Binding result for attribute '%s", this.name);
}
/**
* Verify that the total number of errors is equal to the given one.
* @param expected the expected number of errors
*/
public SELF hasErrorsCount(int expected) {
assertThat(this.actual.getErrorCount())
.as("check errors for attribute '%s'", this.name).isEqualTo(expected);
return this.myself;
}
/**
* Verify that the actual binding result contains fields in error with the
* given {@code fieldNames}.
* @param fieldNames the names of fields that should be in error
*/
public SELF hasFieldErrors(String... fieldNames) {
assertThat(fieldErrorNames()).contains(fieldNames);
return this.myself;
}
/**
* Verify that the actual binding result contains <em>only</em> fields in
* error with the given {@code fieldNames}, and nothing else.
* @param fieldNames the exhaustive list of field name that should be in error
*/
public SELF hasOnlyFieldErrors(String... fieldNames) {
assertThat(fieldErrorNames()).containsOnly(fieldNames);
return this.myself;
}
/**
* Verify that the field with the given {@code fieldName} has an error
* matching the given {@code errorCode}.
* @param fieldName the name of a field in error
* @param errorCode the error code for that field
*/
public SELF hasFieldErrorCode(String fieldName, String errorCode) {
Assertions.assertThat(getFieldError(fieldName).getCode())
.as("check error code for field '%s'", fieldName).isEqualTo(errorCode);
return this.myself;
}
protected AssertionError unexpectedBindingResult(String reason, Object... arguments) {
return this.failures.failure(this.info, new UnexpectedBindingResult(reason, arguments));
}
private AssertProvider<ListAssert<String>> fieldErrorNames() {
return () -> {
List<String> actual = this.actual.getFieldErrors().stream().map(FieldError::getField).toList();
return new ListAssert<>(actual).as("check field errors");
};
}
private FieldError getFieldError(String fieldName) {
FieldError fieldError = this.actual.getFieldError(fieldName);
if (fieldError == null) {
throw unexpectedBindingResult("to have at least an error for field '%s'", fieldName);
}
return fieldError;
}
private final class UnexpectedBindingResult extends BasicErrorMessageFactory {
private UnexpectedBindingResult(String reason, Object... arguments) {
super("%nExpecting binding result:%n %s%n%s", AbstractBindingResultAssert.this.actual,
reason.formatted(arguments));
}
}
}

View File

@@ -0,0 +1,9 @@
/**
* Testing support for validation.
*/
@NonNullApi
@NonNullFields
package org.springframework.test.validation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.test.web.servlet.assertj;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import org.assertj.core.api.AbstractMapAssert;
import org.assertj.core.error.BasicErrorMessageFactory;
import org.assertj.core.internal.Failures;
import org.springframework.lang.Nullable;
import org.springframework.test.validation.AbstractBindingResultAssert;
import org.springframework.validation.BindingResult;
import org.springframework.validation.BindingResultUtils;
import org.springframework.validation.Errors;
import org.springframework.web.servlet.ModelAndView;
/**
* AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to
* a {@linkplain ModelAndView#getModel() model}.
*
* @author Stephane Nicoll
* @since 6.2
*/
public class ModelAssert extends AbstractMapAssert<ModelAssert, Map<String, Object>, String, Object> {
private final Failures failures = Failures.instance();
public ModelAssert(Map<String, Object> map) {
super(map, ModelAssert.class);
}
/**
* Return a new {@linkplain AbstractBindingResultAssert assertion} object
* that uses the {@link BindingResult} with the given {@code name} as the
* object to test.
* Examples: <pre><code class='java'>
* // Check that the "person" attribute in the model has 2 errors:
* assertThat(...).model().extractingBindingResult("person").hasErrorsCount(2);
* </code></pre>
*/
public AbstractBindingResultAssert<?> extractingBindingResult(String name) {
BindingResult result = BindingResultUtils.getBindingResult(this.actual, name);
if (result == null) {
throw unexpectedModel("to have a binding result for attribute '%s'", name);
}
return new BindingResultAssert(name, result);
}
/**
* Verify that the actual model has at least one error.
*/
public ModelAssert hasErrors() {
if (getAllErrors() == 0) {
throw unexpectedModel("to have at least one error");
}
return this.myself;
}
/**
* Verify that the actual model does not have any errors.
*/
public ModelAssert doesNotHaveErrors() {
int count = getAllErrors(); if (count > 0) {
throw unexpectedModel("to not have an error, but got %s", count);
}
return this.myself;
}
/**
* Verify that the actual model contain the attributes with the given
* {@code names}, and that these attributes have each at least one error.
* @param names the expected names of attributes with errors
*/
public ModelAssert hasAttributeErrors(String... names) {
return assertAttributes(names, BindingResult::hasErrors,
"to have attribute errors for", "these attributes do not have any error");
}
/**
* Verify that the actual model contain the attributes with the given
* {@code names}, and that these attributes do not have any error.
* @param names the expected names of attributes without errors
*/
public ModelAssert doesNotHaveAttributeErrors(String... names) {
return assertAttributes(names, Predicate.not(BindingResult::hasErrors),
"to have attribute without errors for", "these attributes have at least an error");
}
private ModelAssert assertAttributes(String[] names, Predicate<BindingResult> condition,
String assertionMessage, String failAssertionMessage) {
Set<String> missing = new LinkedHashSet<>();
Set<String> failCondition = new LinkedHashSet<>();
for (String name : names) {
BindingResult bindingResult = getBindingResult(name);
if (bindingResult == null) {
missing.add(name);
}
else if (!condition.test(bindingResult)) {
failCondition.add(name);
}
}
if (!missing.isEmpty() || !failCondition.isEmpty()) {
StringBuilder sb = new StringBuilder();
sb.append("%n%s:%n %s%n".formatted(assertionMessage, String.join(", ", names)));
if (!missing.isEmpty()) {
sb.append("%nbut could not find these attributes:%n %s%n".formatted(String.join(", ", missing)));
}
if (!failCondition.isEmpty()) {
String prefix = missing.isEmpty() ? "but" : "and";
sb.append("%n%s %s:%n %s%n".formatted(prefix, failAssertionMessage, String.join(", ", failCondition)));
}
throw unexpectedModel(sb.toString());
}
return this.myself;
}
private AssertionError unexpectedModel(String reason, Object... arguments) {
return this.failures.failure(this.info, new UnexpectedModel(reason, arguments));
}
private int getAllErrors() {
return this.actual.values().stream().filter(Errors.class::isInstance).map(Errors.class::cast)
.map(Errors::getErrorCount).reduce(0, Integer::sum);
}
@Nullable
private BindingResult getBindingResult(String name) {
return BindingResultUtils.getBindingResult(this.actual, name);
}
private final class UnexpectedModel extends BasicErrorMessageFactory {
private UnexpectedModel(String reason, Object... arguments) {
super("%nExpecting model:%n %s%n%s", ModelAssert.this.actual, reason.formatted(arguments));
}
}
private static final class BindingResultAssert extends AbstractBindingResultAssert<BindingResultAssert> {
public BindingResultAssert(String name, BindingResult bindingResult) {
super(name, bindingResult, BindingResultAssert.class);
}
}
}