Writing Tests¶
How to describe the expected classes with wrappers, generate the structural tests, and write behavioural tests that still run when the student deviates slightly. Quick Start shows a complete first test; this page is the reference for everything after it.
Wrapper cheat sheet¶
| Wrapper | Constructors | Describes |
|---|---|---|
ClassWrapper<T> |
(name, package, modifiers...)(name, package, superClassWrapper, interfaceWrappers, modifiers...) |
A class, abstract class, interface, enum or record. superClassWrapper == null means "extends Object". |
AttributeWrapper<T, V> |
(this, name, type, modifiers...) |
A field, e.g. (this, "price", double.class, "private") |
MethodWrapper<T, R> |
(this, name, returnType, modifiers...)(this, name, returnType, new Class<?>[]{paramTypes}, modifiers...)(this, name, returnType, new Object[]{paramTypes}, modifiers...) |
A method, e.g. (this, "calculateCost", double.class, new Class<?>[]{int.class}, "public") |
ConstructorWrapper<T> |
(this, "public") (no parameters)(this, new Class<?>[]{paramTypes}, modifiers...)(this, new Object[]{paramTypes}, modifiers...) |
A constructor, e.g. (this, "public") for public Car() |
Where a type is expected (type, returnType, an entry of paramTypes), you can pass a Class<?>, the
ClassWrapper of a student class, or a Supplier<ClassWrapper<?>>. A parameter list that contains a wrapper is an
Object[]; see Student classes as member types.
- Modifiers are plain strings:
"public","private","protected","static","final","abstract", ... A typo throwsIllegalArgumentExceptionas soon as the wrapper is created. Every modifier of the student's element that you do not list is reported as unexpected; mark harmless extras as optional with a?suffix ("public", "final?"). See Modifiers. - Member wrappers must be (non-static) fields of your
ClassWrappersubclass. That's how the structural tests find them. - Every
ClassWrappersubclass implementsgetObj(boolean forceNew), which defines the default instance, usually by delegating togetObj(forceNew, constructorWrapper, args...). - Nothing is looked up when a wrapper is created. Each wrapper looks up its element once, on first use, inside a supervised test.
Inheritance and interfaces¶
Pass the wrappers of the expected superclass and interfaces to the subclass wrapper, and create them in
dependency order (see beforeAll() in
TestManager):
// interface Driveable
driveable = new DrivableWrapper<>();
// abstract class AbstractVehicle
vehicle = new AbstrWrapper<>();
// class Car extends AbstractVehicle implements Driveable
car = new CarWrapper<>(vehicle, driveable);
// inside CarWrapper:
public CarWrapper(ClassWrapper<?> superClass,
ClassWrapper<?>... interfaces) {
super("Car", "org.example.exam", superClass, interfaces, "public");
...
}
The superclass and interface checks compare the classes the wrappers found, so a deviating superclass
name still counts. Inheriting indirectly, or implementing an additional interface, is DEVIATES.
Student classes as member types¶
If an attribute, return or parameter type is itself a student class, pass its ClassWrapper instead of a
Class<?>. The type is looked up only when the member is checked, so:
- a missing student class only makes that type (or the parameters)
MISSING. The member's name and modifiers are still checked, and nothing throws while the wrappers are built; - a misnamed student class within the threshold is found like any other class, and the member uses it;
- two classes that refer to each other work, whichever wrapper is built or checked first.
Messages show the type by its expected simple name (Drummer), also if the class is missing.
Because member wrappers are built in the wrapper's constructor, refer to a class whose wrapper does not exist yet in one of two ways:
// 1. by name: a light wrapper without members, found like any other class
ClassWrapper<?> drummerType = ClassWrapper.reference("Drummer", "org.example.band");
// 2. via a supplier of the full wrapper, evaluated when the member is checked
Supplier<ClassWrapper<?>> drummerType = () -> TestManager.drummer;
Inside GuitaristWrapper's constructor:
drummer = new AttributeWrapper<>(this, "drummer", drummerType, "private");
getDrummer = new MethodWrapper<>(this, "getDrummer", drummerType, "public");
setDrummer = new MethodWrapper<>(this, "setDrummer", void.class, new Object[]{drummerType}, "public");
constructor = new ConstructorWrapper<>(this, new Object[]{double.class, drummerType}, "public");
Parameter lists are Object[] here, each entry a Class<?>, a ClassWrapper or a supplier. A lambda written
directly into the array needs a cast, (Supplier<ClassWrapper<?>>) () -> TestManager.drummer, so prefer a
variable or ClassWrapper.reference(...). A supplier that still returns null when the member is checked throws
IllegalStateException (wrong initialisation order in the test). getExpectedType(), getExpectedReturnType()
and getParamTypes() return null for a student class that is missing.
Structural tests¶
@TestFactory
List<DynamicTest> structure() {
return StructuralLevenshtein.structuralTestFactory(
DetailLevel.ONE_PER_MEMBER_CATEGORY, driveable, vehicle, car);
}
| Detail level | Generated tests |
|---|---|
ONE_FOR_EVERYTHING |
Structural[all] |
ONE_PER_CLASS |
Structural[Car], ... |
ONE_PER_MEMBER_CATEGORY |
Class[Car], Constructors[Car], Attributes[Car], Methods[Car], ... |
ONE_PER_MEMBER |
Class[Car], Constructor[public Car(String, int)], Attribute[Car.price], Method[Car.public void start()], ... |
Pick the level that matches your grading granularity: each generated test is its own test case (on Artemis, too).
- The order is deterministic; duplicate names get a
#2suffix instead of replacing each other. - A group without wrappers gets no test (e.g. no
Attributes[OpenAirStage]for a subclass without own attributes), because it would always pass, also for an untouched template.structuralTestFactory(level, false, wrappers...)still generates them. - Student code only runs inside the generated tests, i.e. supervised by Ares.
- If one element cannot be checked, that element is reported and the rest of the test continues.
Behavioural tests¶
Object car = carWrapper.newObj(); // new default instance
carWrapper.start().invokeOnSpecificObject(car); // student's method
double speed = carWrapper.speed().getValue(car); // (private) attribute
carWrapper.speed().setValue(car, 0.0);
double cost = carWrapper.calculateCost().invoke(); // on the default instance
Object other = carWrapper.constructor().invoke(25000.0);
// getter and setter (see "Getters and setters" below)
carWrapper.testGettersSetters(carWrapper.price(), carWrapper.getPrice(), carWrapper.setPrice());
// expected exception (a MethodWrapper for "void setYear(int)")
IllegalArgumentException e = carWrapper.setYear()
.invokeExpectingException(IllegalArgumentException.class, car, -1);
Every call goes to the element the wrapper found, so a test written for price runs against the student's
prize.
| Call | Target |
|---|---|
invoke(args...), getValue(), setValue(value) |
the cached default instance (getObj()), or null for static members |
invokeOnSpecificObject(obj, args...), getValue(obj), setValue(obj, value) |
obj |
getObj() |
the cached default instance. The cache is only reused for the same constructor and arguments. |
newObj() / getObj(true) / getObj(true, constructorWrapper, args...) |
always a new instance |
setCachedObj(obj) |
pins an object you created yourself as the default instance |
Static members: pass null as the object, or use invoke(...) / getValue().
When the student's element is missing or throws¶
- A
MISSINGelement fails the call with "... is not implemented as expected. See structural Tests for details". ADEVIATESelement (typo, other numeric type, extra modifier, ...) is used as it is. - An exception thrown by student code fails the test with a failure block: the method,
the argument values and the exception's type and message as
Actual:. No stack trace and no cause are attached. AStackOverflowErroradds a hint on endless recursion. - Arrays and collections with more than ten elements are shortened in the
Input:line ([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, … (+90)]). They are shown as they were passed, even if the student's code changed them. - Ares security violations are passed through unchanged, also when a student's
toString()causes one while the message is built; an endlessly recursingtoString()is shown as(toString() threw StackOverflowError). - A failure inside the call (from Ares, a mock or a number conversion) keeps its own message and gets an
Arguments:line and the text ofinvokeWithContext.
Expected exceptions¶
invokeExpectingException(type, obj, args...) (methods) and invokeExpectingException(type, args...)
(constructors) pass if the student's code throws type or a subclass, and return the exception for further
checks. If nothing or something else is thrown, the test fails with a failure block whose Expected: line names
the exception type.
IllegalArgumentException e = carWrapper.constructor()
.invokeExpectingException(IllegalArgumentException.class, -1.0);
assertThat(e.getMessage()).contains("price");
Many generated inputs¶
invokeWithContext(() -> "case " + i + ", seed " + seed, args...) and
invokeOnSpecificObjectWithContext(context, obj, args...) show the text in the Scenario: line of the failure.
The supplier is only evaluated if the call fails. For checks of your own results, report with
Failure and count the failed cases with .cases(failed, total).
Arrays as arguments¶
invoke, invokeOnSpecificObject, invokeExpectingException, getObj(forceNew, constructorWrapper, args...)
and ConstructorWrapper.invoke take Object... args. Java spreads an object array (String[], Integer[],
...) passed there into several arguments; a primitive array (boolean[]) is passed as one. Pass a single array
argument as (Object) array:
A wrong number of arguments is reported as IllegalArgumentException (an error in the test) with this hint.
Numbers¶
Numbers are converted to the student's numeric type: arguments (invoke(2.5) for a student's float
parameter), setValue and results. A value that does not fit (2.5 or 300 for a byte, 1e300 for a
float) fails with a readable message instead of being changed; see Matching.
Utils.safeCast(value, type) converts numeric results in the same way (e.g. int → double) and fails with a
readable message instead of producing a ClassCastException later.
Getters and setters¶
testGettersSetters tests the getter and/or setter of an attribute; you only pass the wrappers. Call it in
your own test method:
@Test
void priceGettersSetters() {
car.testGettersSetters(car.price(), car.getPrice(), car.setPrice()); // getter and setter
car.testGettersSetters(car.price(), car.getPrice()); // getter only (= testGetter)
car.testGettersSetters(car.price(), car.setPrice()); // setter only
car.testGettersSetters(car.price(), (Double p) -> p >= 0, car.getPrice(), car.setPrice()); // setter condition
car.testGettersSetters(car.price(), List.of(1.5, 99.0, 0.0), car.setPrice()); // own sample values
}
- A method wrapper without parameters is the getter, one with one parameter the setter (any order).
- Up to three subtests run, each on a new instance: setter -> attribute (the setter stores the value),
attribute -> getter (a value written into the attribute via reflection is returned; for a
finalattribute only the current value is compared) and setter -> getter. Subtests whose getter or setter was not given are skipped. The cached default instance (getObj()) is not changed. - Sample values are generated for primitives, their wrappers,
Stringand enums: five different values (so hard-coded getters and setters ignoring their argument fail), plusnullfor reference types if no primitive is involved. Other types (arrays, own classes, ...) need own sample values. - A student type with another numeric type (e.g.
floatorintinstead ofdouble) isDEVIATES, and the generated values fit it (whole numbers forint), so the deviation only costs the structural point. Own sample values are used as they are: a value that does not fit (e.g.2.5for anint) fails. - Values are compared by content (
Objects.deepEquals), so a getter or setter may store or return a copy of an array; failure messages show the elements ([true, false]). To require the very same object for reference values, setLevenshteinSettings.setGettersSettersRequireSameObject(true)(see Configuration). - Setter condition (
Predicate, e.g.v -> v != null): values that satisfy it must be stored; for other values the setter must keep the old value or throw an exception. A condition that throws itself (e.g. aNullPointerExceptionfornull) marks the value invalid. Only setters are checked; more complex rules need a behavioural test of your own. - Every subtest runs with every sample value. If a case fails, the test fails with the
failure block of the first failing case and the number of failed cases. The
Scenario:line names the subtest:
Method: getPrice()
Scenario: attribute -> getter, attribute price
Input: price = 2.50
Expected: 2.50
Actual: 30000.00
(10 of 15 cases failed)
- An exception of the getter or setter is shown as
Actual: IllegalStateException: message. For an invalid value (setter condition)Expected:readsvalue rejected (unchanged 2.50 or exception). -
A missing getter, setter or attribute fails with the usual short "... is not implemented as expected".
-
Wrong use (two getters, a method with two parameters, a condition without setter, no sample values for an unsupported type) throws an
IllegalArgumentException.
Reporting failures¶
Behavioural failures share one format: exceptions of student code, invokeExpectingException,
testGettersSetters, and your own checks through Failure. The labels follow
LevenshteinSettings.setLanguage(...); lines that are not set are left out:
Method: calculateLoudness(double, double)
Scenario: generated case 3, seed 2040003
Input: volume = 12.50, crowdNoise = 3.00
Expected: 31.25
Actual: 27.00
Use Failure for your own checks, so students see the same format there:
double actual = wrapper.calculateLoudness().invoke(12.5, 3.0);
if (Math.abs(actual - 31.25) > 1e-9) {
throw Failure.of(wrapper.calculateLoudness()) // Method line: name and parameter types
.scenario("generated case 3, seed 2040003")
.input("volume = 12.50, crowdNoise = 3.00") // or .arguments(12.5, 3.0)
.expected(31.25).actual(actual)
.cases(2, 10) // adds "(2 of 10 cases failed)"
.error();
}
Failure.of(methodWrapper),Failure.of(constructorWrapper)orFailure.of("any text")start a block..expected(value)/.actual(value)format values the same way everywhere (Failure.format):doubleandfloatwith two decimals (LevenshteinSettings.setDecimals(n)changes that; the separator is always a dot), strings in double quotes, characters in single quotes,null, arrays and lists in brackets..expectedText(...)/.actualText(...)show a text as it is..thrown(exception)shows an exception asActual: IllegalStateException: message..error()returns theAssertionErrorto throw. It never has a cause, so students see no stack trace.Failure.call(() -> ...)runs student code and turns any exception into a block (Actual:only); failures and Ares security violations are passed on unchanged.- Report the first failing case only and count the others with
.cases(failed, total).
Structural failures ("Class X in package Y is not implemented as expected.") keep their short form.
Abstract classes and interfaces (Byte Buddy)¶
Abstract classes and interfaces are instantiated through a Byte Buddy subclass, using the actual
constructor (including protected ones). Concrete classes are always created with their own
constructor, so final classes and getClass()-based equals work. Calling an abstract method on such
an instance throws AbstractMethodError; test interface behaviour through an implementing class.
Independent tests and mocks¶
A mistake in one student member should fail only the tests of that member. newObjWithoutConstructor(),
newObjOverriding(...) and mock() create objects without running a student constructor, replace single student
methods with test code and record calls. See Independent Tests and Mocks.
Configuration¶
Configure at runtime in a static initializer of your test class (or in @BeforeAll):
static {
// feedback language, default: DEUTSCH
LevenshteinSettings.setLanguage(LevenshteinSettings.Language.ENGLISH);
// name deviation in percent of the longer name, default: 20 each
LevenshteinSettings.setClassNameDeviationThreshold(10);
LevenshteinSettings.setMethodNameDeviationThreshold(20);
LevenshteinSettings.setAttributeNameDeviationThreshold(20);
// decimals of double/float values in failure messages, default: 2
LevenshteinSettings.setDecimals(2);
// testGettersSetters: equal content (false, default) or the same object (true) for reference values
LevenshteinSettings.setGettersSettersRequireSameObject(false);
// modifiers the specification does not list: DEVIATE (default) or IGNORE
LevenshteinSettings.setUnexpectedModifiers(LevenshteinSettings.UnexpectedModifiers.DEVIATE);
}
| Setting | Default | Effect |
|---|---|---|
setLanguage |
DEUTSCH |
Language of every message (ENGLISH or DEUTSCH) |
set...NameDeviationThreshold |
20 |
How much of a name may differ, see Name matching |
setDecimals |
2 |
Decimals of double/float values in failure blocks (the separator is always a dot) |
setGettersSettersRequireSameObject |
false |
testGettersSetters compares by content, or requires the very same object |
setUnexpectedModifiers |
DEVIATE |
IGNORE reports only an unexpected static of attributes and methods, and a missing expected static makes the element MISSING; see Modifiers |
Set them before the first test
Set the settings in a static initializer or @BeforeAll, not in a test method or @BeforeEach:
@LevenshteinTest looks up the attribute wrappers before each test (to reset attributes that are
unexpectedly static), so a later change does not reach them.
LevenshteinSettings.reset() restores the defaults.
Test annotations and timeouts¶
| Annotation | Use for | Composed of |
|---|---|---|
@LevenshteinTest |
public tests (students see the result) | @Public @StrictTimeout(5) @MirrorOutput @ExtendWith(StaticStateReset.class) |
@HiddenLevenshteinTest |
hidden tests (results shown after the @Deadline) |
@Hidden @StrictTimeout(5) @MirrorOutput @ExtendWith(StaticStateReset.class) |
Both need your own @Policy(...) on the class, and hidden tests also an Ares @Deadline (class or method).
Without an Ares test-type annotation a test runs unsupervised.
A @StrictTimeout on the class or a method overrides the 5 seconds. (regardingTimeouts in the policy is not
enforced by Ares 2.2.1.) A hidden test class looks like a public one:
@HiddenLevenshteinTest
@Deadline("2026-12-01 23:59")
@Policy(value = "test/SecurityPolicy.yaml", withinPath = "classes/org/example/exam")
class CarHiddenTest {
@Test
@StrictTimeout(10) // this test may take longer
void manyRandomCars() { ... }
}
Exercise Variants¶
Keep all expected names and types in one place and read them in the wrappers, as in
example-maven/.../Constants.java:
Organising larger test suites¶
The example splits the tests into three layers: TestManager carries the annotations and the
@Test/@TestFactory methods, TestAbstr/TestImpl/TestInterface hold the test logic as static
methods, and wrappers/* describe the classes. Only the annotated class needs @LevenshteinTest and
@Policy, but every helper class must be listed in theFollowingClassesAreTestClasses.