package org.junit.experimental.theories; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; import org.junit.Assert; import org.junit.Assume; import org.junit.experimental.theories.internal.Assignments; import org.junit.experimental.theories.internal.ParameterizedAssertionError; import org.junit.internal.AssumptionViolatedException; import org.junit.runners.BlockJUnit4ClassRunner; import org.junit.runners.model.FrameworkMethod; import org.junit.runners.model.InitializationError; import org.junit.runners.model.Statement; import org.junit.runners.model.TestClass; /** * The Theories runner allows to test a certain functionality against a subset of an infinite set of data points. * <p> * A Theory is a piece of functionality (a method) that is executed against several data inputs called data points. * To make a test method a theory you mark it with <b>@Theory</b>. To create a data point you create a public * field in your test class and mark it with <b>@DataPoint</b>. The Theories runner then executes your test * method as many times as the number of data points declared, providing a different data point as * the input argument on each invocation. * </p> * <p> * A Theory differs from standard test method in that it captures some aspect of the intended behavior in possibly * infinite numbers of scenarios which corresponds to the number of data points declared. Using assumptions and * assertions properly together with covering multiple scenarios with different data points can make your tests more * flexible and bring them closer to scientific theories (hence the name). * </p> * <p> * For example: * <pre> * * @RunWith(<b>Theories.class</b>) * public class UserTest { * <b>@DataPoint</b> * public static String GOOD_USERNAME = "optimus"; * <b>@DataPoint</b> * public static String USERNAME_WITH_SLASH = "optimus/prime"; * * <b>@Theory</b> * public void filenameIncludesUsername(String username) { * assumeThat(username, not(containsString("/"))); * assertThat(new User(username).configFileName(), containsString(username)); * } * } * </pre> * This makes it clear that the username should be included in the config file name, * only if it doesn't contain a slash. Another test or theory might define what happens when a username does contain * a slash. <code>UserTest</code> will attempt to run <code>filenameIncludesUsername</code> on every compatible data * point defined in the class. If any of the assumptions fail, the data point is silently ignored. If all of the * assumptions pass, but an assertion fails, the test fails. If no parameters can be found that satisfy all assumptions, the test fails. * <p> * Defining general statements as theories allows data point reuse across a bunch of functionality tests and also * allows automated tools to search for new, unexpected data points that expose bugs. * </p> * <p> * The support for Theories has been absorbed from the Popper project, and more complete documentation can be found * from that projects archived documentation. * </p> * * @see <a href="http://web.archive.org/web/20071012143326/popper.tigris.org/tutorial.html">Archived Popper project documentation</a> * @see <a href="http://web.archive.org/web/20110608210825/http://shareandenjoy.saff.net/tdd-specifications.pdf">Paper on Theories</a> */ public class Theories extends BlockJUnit4ClassRunner { public Theories(Class<?> klass) throws InitializationError { super(klass); } @Override protected void collectInitializationErrors(List<Throwable> errors) { super.collectInitializationErrors(errors); validateDataPointFields(errors); validateDataPointMethods(errors); } private void validateDataPointFields(List<Throwable> errors) { Field[] fields = getTestClass().getJavaClass().getDeclaredFields(); for (Field field : fields) { if (field.getAnnotation(DataPoint.class) == null && field.getAnnotation(DataPoints.class) == null) { continue; } if (!Modifier.isStatic(field.getModifiers())) { errors.add(new Error("DataPoint field " + field.getName() + " must be static")); } if (!Modifier.isPublic(field.getModifiers())) { errors.add(new Error("DataPoint field " + field.getName() + " must be public")); } } } private void validateDataPointMethods(List<Throwable> errors) { Method[] methods = getTestClass().getJavaClass().getDeclaredMethods(); for (Method method : methods) { if (method.getAnnotation(DataPoint.class) == null && method.getAnnotation(DataPoints.class) == null) { continue; } if (!Modifier.isStatic(method.getModifiers())) { errors.add(new Error("DataPoint method " + method.getName() + " must be static")); } if (!Modifier.isPublic(method.getModifiers())) { errors.add(new Error("DataPoint method " + method.getName() + " must be public")); } } } @Override protected void validateConstructor(List<Throwable> errors) { validateOnlyOneConstructor(errors); } @Override protected void validateTestMethods(List<Throwable> errors) { for (FrameworkMethod each : computeTestMethods()) { if (each.getAnnotation(Theory.class) != null) { each.validatePublicVoid(false, errors); each.validateNoTypeParametersOnArgs(errors); } else { each.validatePublicVoidNoArg(false, errors); } for (ParameterSignature signature : ParameterSignature.signatures(each.getMethod())) { ParametersSuppliedBy annotation = signature.findDeepAnnotation(ParametersSuppliedBy.class); if (annotation != null) { validateParameterSupplier(annotation.value(), errors); } } } } private void validateParameterSupplier(Class<? extends ParameterSupplier> supplierClass, List<Throwable> errors) { Constructor<?>[] constructors = supplierClass.getConstructors(); if (constructors.length != 1) { errors.add(new Error("ParameterSupplier " + supplierClass.getName() + " must have only one constructor (either empty or taking only a TestClass)")); } else { Class<?>[] paramTypes = constructors[0].getParameterTypes(); if (!(paramTypes.length == 0) && !paramTypes[0].equals(TestClass.class)) { errors.add(new Error("ParameterSupplier " + supplierClass.getName() + " constructor must take either nothing or a single TestClass instance")); } } } @Override protected List<FrameworkMethod> computeTestMethods() { List<FrameworkMethod> testMethods = new ArrayList<FrameworkMethod>(super.computeTestMethods()); List<FrameworkMethod> theoryMethods = getTestClass().getAnnotatedMethods(Theory.class); testMethods.removeAll(theoryMethods); testMethods.addAll(theoryMethods); return testMethods; } @Override public Statement methodBlock(final FrameworkMethod method) { return new TheoryAnchor(method, getTestClass()); } public static class TheoryAnchor extends Statement { private int successes = 0; private final FrameworkMethod testMethod; private final TestClass testClass; private List<AssumptionViolatedException> fInvalidParameters = new ArrayList<AssumptionViolatedException>(); public TheoryAnchor(FrameworkMethod testMethod, TestClass testClass) { this.testMethod = testMethod; this.testClass = testClass; } private TestClass getTestClass() { return testClass; } @Override public void evaluate() throws Throwable { runWithAssignment(Assignments.allUnassigned( testMethod.getMethod(), getTestClass())); //if this test method is not annotated with Theory, then no successes is a valid case boolean hasTheoryAnnotation = testMethod.getAnnotation(Theory.class) != null; if (successes == 0 && hasTheoryAnnotation) { Assert .fail("Never found parameters that satisfied method assumptions. Violated assumptions: " + fInvalidParameters); } } protected void runWithAssignment(Assignments parameterAssignment) throws Throwable { if (!parameterAssignment.isComplete()) { runWithIncompleteAssignment(parameterAssignment); } else { runWithCompleteAssignment(parameterAssignment); } } protected void runWithIncompleteAssignment(Assignments incomplete) throws Throwable { for (PotentialAssignment source : incomplete .potentialsForNextUnassigned()) { runWithAssignment(incomplete.assignNext(source)); } } protected void runWithCompleteAssignment(final Assignments complete) throws Throwable { new BlockJUnit4ClassRunner(getTestClass().getJavaClass()) { @Override protected void collectInitializationErrors( List<Throwable> errors) { // do nothing } @Override public Statement methodBlock(FrameworkMethod method) { final Statement statement = super.methodBlock(method); return new Statement() { @Override public void evaluate() throws Throwable { try { statement.evaluate(); handleDataPointSuccess(); } catch (AssumptionViolatedException e) { handleAssumptionViolation(e); } catch (Throwable e) { reportParameterizedError(e, complete .getArgumentStrings(nullsOk())); } } }; } @Override protected Statement methodInvoker(FrameworkMethod method, Object test) { return methodCompletesWithParameters(method, complete, test); } @Override public Object createTest() throws Exception { Object[] params = complete.getConstructorArguments(); if (!nullsOk()) { Assume.assumeNotNull(params); } return getTestClass().getOnlyConstructor().newInstance(params); } }.methodBlock(testMethod).evaluate(); } private Statement methodCompletesWithParameters( final FrameworkMethod method, final Assignments complete, final Object freshInstance) { return new Statement() { @Override public void evaluate() throws Throwable { final Object[] values = complete.getMethodArguments(); if (!nullsOk()) { Assume.assumeNotNull(values); } method.invokeExplosively(freshInstance, values); } }; } protected void handleAssumptionViolation(AssumptionViolatedException e) { fInvalidParameters.add(e); } protected void reportParameterizedError(Throwable e, Object... params) throws Throwable { if (params.length == 0) { throw e; } throw new ParameterizedAssertionError(e, testMethod.getName(), params); } private boolean nullsOk() { Theory annotation = testMethod.getMethod().getAnnotation( Theory.class); if (annotation == null) { return false; } return annotation.nullsAccepted(); } protected void handleDataPointSuccess() { successes++; } } }