-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cherrypick:Added side-effect-free stubbing framework for SOFABoot app…
…lications #1224
- Loading branch information
致节
committed
Aug 25, 2023
1 parent
8d708cb
commit 97e0fbe
Showing
37 changed files
with
3,577 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
...t/src/main/java/com/alipay/sofa/test/mock/injector/InjectorMockTestExecutionListener.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |
152 changes: 152 additions & 0 deletions
152
...fa-boot/src/main/java/com/alipay/sofa/test/mock/injector/annotation/MockBeanInjector.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"> | ||
* @RunWith(SpringRunner.class) | ||
* public class ExampleServiceTest { | ||
* | ||
* @Autowired | ||
* private ExampleService service; | ||
* | ||
* @MockBeanInjector(type = ExampleService.class, field = "fieldA") | ||
* private FieldAClass mock; | ||
* | ||
* @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 | ||
* @Import(ExampleService.class) // A @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"> | ||
* @RunWith(SpringRunner.class) | ||
* public class ExampleTests { | ||
* | ||
* @MockBeanInjector(type = ExampleService.class, field = "fieldA") | ||
* @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; | ||
} |
Oops, something went wrong.