Skip to content

Commit

Permalink
cherrypick:Added side-effect-free stubbing framework for SOFABoot app…
Browse files Browse the repository at this point in the history
…lications #1224
  • Loading branch information
致节 committed Aug 25, 2023
1 parent 8d708cb commit 97e0fbe
Show file tree
Hide file tree
Showing 37 changed files with 3,577 additions and 0 deletions.
23 changes: 23 additions & 0 deletions sofa-boot-project/sofa-boot-core/test-sofa-boot/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,35 @@
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>com.alipay.sofa</groupId>
<artifactId>isle-sofa-boot</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>provided</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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
*
* http://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 com.alipay.sofa.test.mock.injector;

import com.alipay.sofa.test.mock.injector.annotation.MockBeanInjector;
import com.alipay.sofa.test.mock.injector.annotation.SpyBeanInjector;
import com.alipay.sofa.test.mock.injector.definition.Definition;
import com.alipay.sofa.test.mock.injector.parser.DefinitionParser;
import com.alipay.sofa.test.mock.injector.resolver.BeanInjectorResolver;
import com.alipay.sofa.test.mock.injector.resolver.BeanInjectorStub;
import org.springframework.boot.test.mock.mockito.MockReset;
import org.springframework.core.Ordered;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.TestExecutionListener;
import org.springframework.test.context.support.AbstractTestExecutionListener;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ReflectionUtils;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Set;

/**
* {@link TestExecutionListener} to enable {@link MockBeanInjector} and
* {@link SpyBeanInjector} support.
*
* @author pengym
* @version InjectorMockTestExecutionListener.java, v 0.1 2023年08月07日 15:51 pengym
*/
public class InjectorMockTestExecutionListener extends AbstractTestExecutionListener {

static final String STUBBED_FIELDS = "_SOFA_BOOT_STUBBED_FIELDS";

static final String STUBBED_DEFINITIONS = "_SOFA_BOOT_STUBBED_DEFINITIONS";

@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}

@Override
public void prepareTestInstance(TestContext testContext) {
// parse annotations and inject fields
injectFields(testContext);
}

private void injectFields(TestContext testContext) {
// create definitions form annotation
DefinitionParser parser = new DefinitionParser();
parser.parse(testContext.getTestClass());
testContext.setAttribute(STUBBED_DEFINITIONS, parser.getDefinitions());

// create stubs form definitions
Collection<BeanInjectorStub> beanInjectorStubs = createStubs(parser, testContext);
testContext.setAttribute(STUBBED_FIELDS, beanInjectorStubs);

// inject mock/spy to test class fields
injectTestClass(parser, testContext);
}

private void injectTestClass(DefinitionParser parser, TestContext testContext) {
parser.getDefinitions().forEach(definition -> {
Field field = parser.getField(definition);
if (field != null) {
Object target = testContext.getTestInstance();
ReflectionUtils.makeAccessible(field);
Object existingValue = ReflectionUtils.getField(field, target);
Object injectValue = definition.getMockInstance();
if (existingValue == injectValue) {
return;
}
Assert.state(existingValue == null, () -> "The existing value '" + existingValue + "' of field '" + field
+ "' is not the same as the new value '" + injectValue + "'");
ReflectionUtils.setField(field, target, injectValue);
}
});
}

private Collection<BeanInjectorStub> createStubs(DefinitionParser parser,
TestContext testContext) {
Collection<BeanInjectorStub> beanInjectorStubs = new ArrayList<>();
BeanInjectorResolver resolver = new BeanInjectorResolver(
testContext.getApplicationContext());
for (Definition definition : parser.getDefinitions()) {
BeanInjectorStub field = resolver.resolveStub(definition);
if (field != null) {
beanInjectorStubs.add(field);
}
}
return beanInjectorStubs;
}

@Override
@SuppressWarnings("unchecked")
public void beforeTestMethod(TestContext testContext) {
Set<Definition> stubbedDefinitions = (Set<Definition>) testContext.getAttribute(STUBBED_DEFINITIONS);
if (!CollectionUtils.isEmpty(stubbedDefinitions)) {
stubbedDefinitions.stream().filter(definition -> definition.getReset().equals(MockReset.BEFORE)).forEach(Definition::resetMock);
}
}

@Override
@SuppressWarnings("unchecked")
public void afterTestMethod(TestContext testContext) {
Set<Definition> stubbedDefinitions = (Set<Definition>) testContext.getAttribute(STUBBED_DEFINITIONS);
if (!CollectionUtils.isEmpty(stubbedDefinitions)) {
stubbedDefinitions.stream().filter(definition -> definition.getReset().equals(MockReset.AFTER)).forEach(Definition::resetMock);
}
Collection<BeanInjectorStub> beanStubbedFields = (Collection<BeanInjectorStub>) testContext.getAttribute(STUBBED_FIELDS);
if (!CollectionUtils.isEmpty(beanStubbedFields)) {
beanStubbedFields.forEach(BeanInjectorStub::reset);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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
*
* http://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 com.alipay.sofa.test.mock.injector.annotation;

import org.mockito.Answers;
import org.mockito.MockSettings;
import org.springframework.boot.test.mock.mockito.MockReset;
import org.springframework.core.annotation.AliasFor;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation that can be used to create mocks and inject mock to a target bean's field.
* <p>
* Injector target bean can be found by type or by {@link #name() bean name}. When registered by
* type, any existing single bean of a matching type (including subclasses) in the context
* will be found for injector. If no suitable bean could be found, {@link IllegalStateException} will be thrown.
* <p>
* Field in target bean will be found by {@link #field()}. If no field could be found, {@link IllegalStateException} will be thrown.
* <p>
*
* Typical usage might be: <pre class="code">
* &#064;RunWith(SpringRunner.class)
* public class ExampleServiceTest {
*
* &#064;Autowired
* private ExampleService service;
*
* &#064;MockBeanInjector(type = ExampleService.class, field = "fieldA")
* private FieldAClass mock;
*
* &#064;Test
* public void testInjectExampleServiceFieldA() {
* // 1. mock external dependency
* given(mock.callSomeMethod(...))
* .willReturn(...);
*
* // 2. perform testing
* service.doSomething();
*
* // 3. behavioral-driven testing / standard unit-testing
* then(mock)
* .should(atLeastOnce())
* .callSomeMethod(...);
*
* assertThat(...)...;
* }
*
* #064;Configuration
* &#064;Import(ExampleService.class) // A &#064;Component injected with ExampleService
* static class Config {
* }
* }
* </pre>
* If there is more than one bean of the requested type, qualifier metadata must be
* specified at field level: <pre class="code">
* &#064;RunWith(SpringRunner.class)
* public class ExampleTests {
*
* &#064;MockBeanInjector(type = ExampleService.class, field = "fieldA")
* &#064;Qualifier("example")
* private ExampleService service;
*
* ...
* }
* </pre>
* @author pengym
* @version MockBeanInjector.java, v 0.1 2023年08月07日 15:32 pengym
*/
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MockBeanInjector {

/**
* The name for field which should inject the mock.
* <p> When can not find the target field, an {@link IllegalStateException} will be thrown.
*/
String field();

/**
* The name of the bean to inject the mock to a field.
* @return the name of the target bean
*/
String name() default "";

/**
* The class type of the bean to inject the mock to a field. This is an alias of {@link #type()} which can be used for
* brevity if no other attributes are defined. See {@link #type()} for details.
* @return the class ype of the target bean
*/
@AliasFor("type")
Class<?> value() default void.class;

/**
* The class type of the bean to inject the mock to a field
* @return the class ype of the target bean
*/
@AliasFor("value")
Class<?> type() default void.class;

/**
* The application context id to find the target bean. If not specified, the root application context will be used.
* <p> When can not find the target SOFA module for the specified module name, an {@link IllegalStateException} will be thrown.
*/
String module() default "";

/**
* Any extra interfaces that should also be declared on the mock. See
* {@link MockSettings#extraInterfaces(Class...)} for details.
* @return any extra interfaces
*/
Class<?>[] extraInterfaces() default {};

/**
* The {@link Answers} type to use on the mock.
* @return the answer type
*/
Answers answer() default Answers.RETURNS_DEFAULTS;

/**
* If the generated mock is serializable. See {@link MockSettings#serializable()} for
* details.
* @return if the mock is serializable
*/
boolean serializable() default false;

/**
* The reset mode to apply to the mock. The default is {@link MockReset#AFTER}
* meaning that mocks are automatically reset after each test method is invoked.
* @return the reset mode
*/
MockReset reset() default MockReset.AFTER;
}
Loading

0 comments on commit 97e0fbe

Please sign in to comment.