Skip to content

Commit

Permalink
JUnit rules support. Includes supporting rules in the Test Class if …
Browse files Browse the repository at this point in the history
…present, and supporting mix-in objects applied in the hierarchy as a special type of supplier. Updated the Hooks mechanism from greghaskins#77 to allow the passing of RunNotifier and Description around, and hooked in a private version of the rules handling code from JUnit which adapts from Hooks to Statement objects. Demonstrated the whole thing with Spring, Mockito and JUnit native rules, and managed to reverse some of the changes to exception handling brought by greghaskins#77
  • Loading branch information
Ashley Frieze committed Jan 16, 2017
1 parent 659a38c commit 00ed8a6
Show file tree
Hide file tree
Showing 32 changed files with 1,138 additions and 65 deletions.
123 changes: 123 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,128 @@ feature("Gherkin-like test DSL", () -> {
When using the Gherkin syntax, each `given`/`when`/`then` step must pass before the next is run. Note that they must be declared inside a `scenario` block to work correctly. Multiple `scenario` blocks can be defined as part of a `feature`.
### JUnit Rules
Spectrum's runner works differently to the normal JUnit `ParentRunner` and `BlockJUnitRunner` derived test runners. In JUnit you normally have a new instance of the test class for every single test method to work with. As Spectrum uses the test class to write functional definitions of tests, there is only a single instance of the test object used throughout.

To enable users to mix in features from across the JUnit ecosystem, there are two ways you can add JUnit behaviour to Spectrum tests.

* You can wire in Rules classes using `applyRules` - these provide multiple instances of the test object of that rules class and execute JUnit `@Rule` directives within it along the way.
* You _can_ use the Java class within which you have declared the Spectrum tests. This can contain local variables and `@Rule` annotated objects. They will be reused over the course of the test.

The Spectrum native approach is the safest and cleanest, but is less familiar to JUnit users. The native JUnit approach will work for many cases, but may cause problems with some third party rules.

#### Spectrum style

##### Step 1 - create a class with your JUnit rules in it.

In Spectrum's own test cases, the mix-in class is a `public static class` inside the test class. This is one option. It does not matter whether the rules class is an inner class, or whether it's external, so long as it is public and has a default constructor. Making these mix-in classes as external reusable objects may be a useful way to modularise testing. It is up to you whether you make the fields accessible, or put getters on them. For simplicity here is an example with accessible fields:

```java
public class TestRuleMixin {
@Rule
public TemporaryFolder tempFolderRule = new TemporaryFolder();
}
```

##### Step 2 - use that junit.rule within your tests with `applyRules`

The `applyRules` function returns a `Supplier`. That supplier's `get` function will allow you to access the current instance of the mix-in object during your tests/specs. The rules mentioned will have been executed already.
```java
@RunWith(Spectrum.class)
public class SpectrumSpec {{
Supplier<TestRuleMixin> testObject = applyRules(TestRuleMixin.class);
describe("a set of test specs", () -> {
it("has a fresh copy of the test object here", () -> {
// testObject.get() gives us one instance here having run
});
it("has a different fresh copy of the test object here", () -> {
// testObject.get() gives us another instance here too
});
});
}}
```
The rules are applied and the test object created just in time for each atomic test within the describe blocks etc. An atomic test is either an `it` level test or a `compositeTest` for example a `GherkinSyntax` `scenario`.
The `applyRules` function causes a fresh initialisation of the mix-in object for every atomic child anywhere in the hierarchy following where `applyRules` is called. This might have adverse side effects if your rules are setting up a large ecosystem that you wish to reuse across tests.
The alternative `applyRulesHere` causes the mix-in object only to be created for each *immediate* child of the suite in which
it is called.
E.g.
```java
@RunWith(Spectrum.class)
public class SpectrumSpec {{
Supplier<TestRuleMixin> testObject = applyRulesHere(TestRuleMixin.class);
describe("a set of test specs", () -> {
it("has a fresh copy of the test object here", () -> {
// testObject.get() gives us one instance here having run
});
it("has the same copy of the test object here", () -> {
// testObject.get() gives us the same instance
});
});
describe("this sibling would get a fresh set of the rules-applied test object" () -> {});
}}
```
#### JUnit style
With many of the JUnit rules, you can pretend that Spectrum works like JUnit and ignore the issue of the test object being reused. When things stop working, move to Spectrum style.
```java
@RunWith(Spectrum.class)
public class SpectrumSpec {
@Rule
public TemporaryFolder tempFolderRule = new TemporaryFolder();
{
describe("a set of test specs", () -> {
it("has a freshly prepared tempFolderRule", () -> {
// tempFolderRule gives us one folder here having been set up by the junit.rule
});
it("has a different fresh copy of the test object here", () -> {
// tempFolderRule gives us another folder here too
});
});
}}
```
#### What is provided
* `@ClassRule` is applied
* `@BeforeAll` is applied
* `@AfterAll` is applied
* `@Rule` objects:
* `TestRule`s are applied at the level of each atomic test
* `MethodRule`s are applied at the level of each atomic test
* `applyRules` and `applyRulesHere` are implemented to be thread-safe
#### What is not supported
Native JUnit allows you to put annotations on specific test methods for the rules to pick up.
E.g.
```java
@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
public void willDoSomethingDestructiveToSpring() throws Exception {
myBean.deleteAll();
}
```
This is not yet supported in Spectrum. You can work around it for Spring by using different mix-in classes with different class-level annotations to control this sort of behaviour, and segmenting your test suite so that specs that need certain behaviour are within one `describe` block etc.
#### Examples
> See: [JUnitRulesExample](src/test/java/specs/JUnitRulesExample.java),
[MockitoSpecJUnitStyle](src/test/java/specs/MockitoSpecJUnitStyle.java),
[MockitoSpecWithRuleClasses](src/test/java/specs/MockitoSpecWithRuleClasses.java),
[SpringSpecJUnitStyle](src/test/java/specs/SpringSpecJUnitStyle.java) and
[SpringSpecWithRuleClasses](src/test/java/specs/SpringSpecWithRuleClasses.java)
## Supported Features
The Spectrum API is designed to be familiar to Jasmine and RSpec users, while remaining compatible with JUnit. The features and behavior of those libraries help guide decisions on how Specturm should work, both for common scenarios and edge cases. (See [the discussion on #41](https://github.com/greghaskins/spectrum/pull/41#issuecomment-238729178) for an example of how this factors into design decisions.)
Expand All @@ -449,6 +571,7 @@ Spectrum also supports:
- Compatibility with existing JUnit tools; no configuration required
- Mixing Spectrum tests and normal JUnit tests in the same project suite
- RSpec-style `aroundEach` and `aroundAll` hooks for advanced users and plugin authors
- Plugging in familiar JUnit-friendly libraries like Mockito or SpringJUnit via JUnit `@Rules` handling.
### Non-Features
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ dependencies {
compile group: 'junit', name: 'junit', version: '4.12'
testCompile group: 'org.hamcrest', name: 'hamcrest-library', version: '1.3'
testCompile group: 'org.mockito', name: 'mockito-core', version: '1.10.19'
testCompile group: 'org.springframework', name:'spring-test', version: '4.3.4.RELEASE'
testCompile group: 'org.springframework', name:'spring-core', version: '4.3.4.RELEASE'
testCompile group: 'org.springframework', name:'spring-context', version: '4.3.4.RELEASE'
}

compileJava { sourceCompatibility = 1.8 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import static com.greghaskins.spectrum.Spectrum.assertSpectrumInTestMode;

import org.junit.runner.Description;
import org.junit.runner.notification.RunNotifier;

/**
* A base class for supplying hooks to use. Override before or after. Return the singleton
* value from the before method.
Expand All @@ -23,11 +26,14 @@ protected void after() {}

/**
* Template method for a hook which supplies.
* @param description description - unused here
* @param runNotifier runNotifier - unused here
* @param block the inner block that will be run
* @throws Throwable on error
*/
@Override
public void acceptOrThrow(Block block) throws Throwable {
public void accept(final Description description, final RunNotifier runNotifier,
final Block block) throws Throwable {
try {
set(before());
block.run();
Expand Down
25 changes: 23 additions & 2 deletions src/main/java/com/greghaskins/spectrum/Hook.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
package com.greghaskins.spectrum;

import org.junit.runner.Description;
import org.junit.runner.notification.RunNotifier;

/**
* A hook allows you to inject functionality before and/or after a {@link Block}.
* Just implement the {@link ThrowingConsumer#acceptOrThrow(Object)} method and
* Just implement the {@link #accept(Description, RunNotifier, Block)} method and
* call {@link Block#run()} within your implementation.
* If your hook is going to provide an object to the running test, then implement
* {@link SupplyingHook} or subclass {@link AbstractSupplyingHook}.
*/
public interface Hook extends ThrowingConsumer<Block> {
public interface Hook {
/**
* Accept the block and execute it, hooking in any behaviour around it.
* @param description description of where we are in the test
* @param notifier the notifier for failures
* @param block the block to execute
* @throws Throwable on error
*/
void accept(final Description description, final RunNotifier notifier,
final Block block) throws Throwable;

/**
* Create a hook from a {@link ThrowingConsumer}.
* @param consumer to turn into a hook
* @return the hook
*/
static Hook from(ThrowingConsumer<Block> consumer) {
return (description, notifier, block) -> consumer.accept(block);
}
}
5 changes: 5 additions & 0 deletions src/main/java/com/greghaskins/spectrum/Spec.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,9 @@ public void ignore() {
public boolean isAtomic() {
return true;
}

@Override
public boolean isEffectivelyIgnored() {
return ignored;
}
}
60 changes: 56 additions & 4 deletions src/main/java/com/greghaskins/spectrum/Spectrum.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import static com.greghaskins.spectrum.model.BlockConfiguration.Factory.ignore;

import com.greghaskins.spectrum.internal.LetHook;
import com.greghaskins.spectrum.internal.junit.Rules;
import com.greghaskins.spectrum.model.ConstructorBlock;
import com.greghaskins.spectrum.model.HookContext;
import com.greghaskins.spectrum.model.IdempotentBlock;
Expand Down Expand Up @@ -222,6 +223,30 @@ public static Configuration configure() {
}


/**
* Implements a rules context within the current suite using the given rules class as a mix-in.
* These rules will cascade down and be applied at the level of specs or atomic specs.
* @param rulesClass type of object to create and apply rules to for each spec.
* @param <T> type of the object
* @return a supplier of the rules object
*/
public static <T> Supplier<T> applyRules(final Class<T> rulesClass) {
return Rules.applyRules(rulesClass);
}

/**
* Implements a rules context within the current suite using the given rules class as a mix-in.
* These rules will only run for each immediate child of the suite. If the rules should
* run fresh in a fresh rules object for each atomic spec
* ({@link #it(String, com.greghaskins.spectrum.Block)} etc) then use {@link #applyRules(Class)}
* @param rulesClass type of object to create and apply rules to for each spec.
* @param <T> type of the object
* @return a supplier of the rules object
*/
public static <T> Supplier<T> applyRulesHere(final Class<T> rulesClass) {
return Rules.applyRulesHere(rulesClass);
}

/**
* Declare a {@link com.greghaskins.spectrum.Block} to be run before each spec in the suite.
*
Expand Down Expand Up @@ -292,7 +317,7 @@ public static void afterAll(final com.greghaskins.spectrum.Block block) {
* @param consumer to run each spec block
*/
public static void aroundEach(ThrowingConsumer<com.greghaskins.spectrum.Block> consumer) {
addHook(new HookContext(consumer::acceptOrThrow, getDepth(),
addHook(new HookContext(Hook.from(consumer), getDepth(),
HookContext.AppliesTo.ATOMIC_ONLY, HookContext.Precedence.GUARANTEED_CLEAN_UP_LOCAL));
}

Expand All @@ -304,7 +329,7 @@ public static void aroundEach(ThrowingConsumer<com.greghaskins.spectrum.Block> c
* @param consumer to run each spec block
*/
public static void aroundAll(ThrowingConsumer<com.greghaskins.spectrum.Block> consumer) {
addHook(new HookContext(consumer::acceptOrThrow, getDepth(),
addHook(new HookContext(Hook.from(consumer), getDepth(),
HookContext.AppliesTo.ONCE, HookContext.Precedence.OUTER));
}

Expand All @@ -331,6 +356,22 @@ public static <T> Supplier<T> let(final com.greghaskins.spectrum.ThrowingSupplie
return letHook;
}

/**
* Insert a hook into the current level of definition.
* @param hook to insert
* @param appliesTo the {@link com.greghaskins.spectrum.model.HookContext.AppliesTo} indicating
* where the hook is run
* @param precedence the importance of the hook compared to others
*/
public static void addHook(final Hook hook, final HookContext.AppliesTo appliesTo,
final HookContext.Precedence precedence) {
addHook(new HookContext(hook, getDepth(), appliesTo, precedence));
}

private static void addHook(HookContext hook) {
getCurrentSuiteBeingDeclared().addHook(hook);
}

/**
* Will throw an exception if this method happens to be called while Spectrum is still defining
* tests, rather than executing them. Useful to see if a hook is being accidentally used
Expand Down Expand Up @@ -376,8 +417,10 @@ public void run(final RunNotifier notifier) {
private static void beginDefinition(final Suite suite,
final com.greghaskins.spectrum.Block definitionBlock) {
getSuiteStack().push(suite);

try {
definitionBlock.run();
addRootJUnitHook(definitionBlock);
} catch (final Throwable error) {
suite.removeAllChildren();
it("encountered an error", () -> {
Expand All @@ -399,7 +442,16 @@ private static Suite getCurrentSuiteBeingDeclared() {
return getSuiteStack().peek();
}

private static void addHook(HookContext hook) {
getCurrentSuiteBeingDeclared().addHook(hook);
/**
* If the definition is at the right level and the block is the right sort
* of block, then this is might be an opportunity to hook in JUnit rules.
* @param definitionBlock the block used to make this suite
*/
private static void addRootJUnitHook(com.greghaskins.spectrum.Block definitionBlock) {
if (getDepth() == 1) {
if (definitionBlock instanceof ConstructorBlock) {
Rules.applyRules(((ConstructorBlock) definitionBlock).get());
}
}
}
}
33 changes: 28 additions & 5 deletions src/main/java/com/greghaskins/spectrum/Suite.java
Original file line number Diff line number Diff line change
Expand Up @@ -200,23 +200,34 @@ public void run(final RunNotifier notifier) {
}

private void runSuite(final RunNotifier notifier) {
hooks.once().sorted()
.runAround(description, notifier, () -> runChildren(notifier));
if (isEffectivelyIgnored()) {
runChildren(notifier);
} else {
hooks.once().sorted()
.runAround(description, notifier, () -> runChildren(notifier));
}
}

private void runChildren(final RunNotifier notifier) {
this.childRunner.runChildren(this, notifier);
}

protected void runChild(final Child child, final RunNotifier notifier) {
if (this.focusedChildren.isEmpty() || this.focusedChildren.contains(child)) {
if (child.isEffectivelyIgnored()) {
// running the child will make it act ignored
child.run(notifier);
} else if (childIsNotInFocus(child)) {
notifier.fireTestIgnored(child.getDescription());
} else {
hooks.forThisLevel().sorted().runAround(child.getDescription(), notifier,
() -> runChildWithHooks(child, notifier));
} else {
notifier.fireTestIgnored(child.getDescription());
}
}

private boolean childIsNotInFocus(Child child) {
return !this.focusedChildren.isEmpty() && !this.focusedChildren.contains(child);
}

private void runChildWithHooks(final Child child, final RunNotifier notifier) {
getHooksFor(child).sorted().runAround(child.getDescription(), notifier,
() -> child.run(notifier));
Expand Down Expand Up @@ -246,4 +257,16 @@ private static void defaultChildRunner(final Suite suite, final RunNotifier runN
private String sanitise(final String name) {
return nameSanitiser.sanitise(name);
}

@Override
public boolean isEffectivelyIgnored() {
return ignored || !hasANonIgnoredChild();
}

private boolean hasANonIgnoredChild() {
return children.stream()
.filter(child -> !child.isEffectivelyIgnored())
.findFirst()
.isPresent();
}
}

0 comments on commit 00ed8a6

Please sign in to comment.