I’ve created a simple example of how a system test can be written for JMonkeyengine. Its at GitHub - richardTingle/jmeSystemTestExample: A minimal example of a JMonkey engine being system tested but I’ll describe the main classes here.
I’ve considered the following key principles
- A test is a more or less linear series of steps and checks; a script that runs from top to bottom.
- A JMonkey application is a continuously updating application (it has an update loop that runs 60 times a second and it shouldn’t need to care about what the test is doing).
System Test
So the test I created looks like the following
@Test(timeout = 30000)
public void exampleSystemTest() {
TestDriver testDriver = TestDriver.bootAppForTest();
//mutate the app, giving the box a velocity
testDriver.actOnTick( app -> app.setBoxVelocity(new Vector3f(0,0.1f,0)));
//wait for the box to move
testDriver.waitFor(5);
//check it's where we expect it to be after that 5 seconds has elapsed
float boxHeight = testDriver.getOnTick(app -> (float)app.getBoxPosition().y);
assertEquals(0.5f,boxHeight, 0.1f );
}
That looks more or less like a normal unit test, but it has some interesting things in it, like `testDriver.waitFor(5). That shows why the test and app must be on different threads. The test wants to wait, but the app wants to just do whatever it would normally do over those 5 seconds (during which the box will slowly move).
testDriver.actOnTick
and testDriver.getOnTick
allow the test to schedule things to happen within the applications thread while waiting for that to happen. You could probably get away with directly modifying and querying the app from within the test in this case but its a bad idea, you don’t know what the app will happen to be doing while you’re mutating it from another thread so more complex things could fail badly if directly mutated.
TestDriver
The test driver provides a safe way to interact with the main application. Its basically just a queue of code to run, and the application will run the code when its the right point in the update tick. Note that everything I have here blocks the test, but in my real code not everything is blocking. I call these non-blocking things watches
as they typically take readings over a period while the test may execute further commands, then later the watch is asked what it observed.
public class TestDriver extends BaseAppState{
private static Executor executor = Executors.newSingleThreadExecutor( (r) -> {
Thread thread = new Thread(r);
thread.setDaemon(true);
return thread;
});
private CopyOnWriteArrayList<InAppEvent> actOnTickQueue = new CopyOnWriteArrayList<>();
@Override
public void update(float tpf){
super.update(tpf);
actOnTickQueue.forEach(event -> {
event.timeTillRun-=tpf;
if (event.timeTillRun<0){
actOnTickQueue.remove(event);
event.runnable.accept((App)getApplication());
synchronized(event.waitObject){
event.waitObject.notify();
}
}
});
}
/**
* Passes a piece of code to run within the game's update thread.
*
* Blocks the calling thread until that tick has occurred
*/
public void actOnTick(Consumer<App> runnable){
getOnTick(app -> {
runnable.accept(app);
return null;
} );
}
/**
* Obtains some object from the application within the update tick. Blocks until the object is obtained
*/
public <T> T getOnTick(Function<App, T> obtainFunction){
List<T> mutable = new ArrayList<>();
Object waitObject = new Object();
actOnTickQueue.add(new InAppEvent((app) -> mutable.add(obtainFunction.apply(app)), 0, waitObject));
synchronized(waitObject){
try{
waitObject.wait();
} catch(InterruptedException e){
throw new RuntimeException(e);
}
}
return mutable.get(0);
}
/**
* Pauses the test until the specified time has elapsed
* @param timeToWait
*/
public void waitFor(double timeToWait){
Object waitObject = new Object();
actOnTickQueue.add(new InAppEvent((app) -> {}, timeToWait, waitObject));
synchronized(waitObject){
try{
waitObject.wait();
} catch(InterruptedException e){
throw new RuntimeException(e);
}
}
}
/**
* Boots up the application on a separate thread (blocks until that happens) and returns a test driver that can be
* used to safely interact with it
*/
public static TestDriver bootAppForTest(){
TestDriver testDriver = new TestDriver();
App app = new App(testDriver);
AppSettings appSettings = new AppSettings(true);
appSettings.setFrameRate(60);
app.setSettings(appSettings);
app.setShowSettings(false);
executor.execute(() -> app.start());
while( !app.hasBeenInitialised){
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return testDriver;
}
private static class InAppEvent{
Consumer<App> runnable;
double timeTillRun;
final Object waitObject;
public InAppEvent(Consumer<App> runnable, double timeTillRun, Object waitObject){
this.runnable = runnable;
this.timeTillRun = timeTillRun;
this.waitObject = waitObject;
}
}
}
The app itself is pretty normal and doesn’t really need to know its under test (other than the hasBeenInitialised which probably could be avoided now I think about it by using a similar concept but putting it in the TestDriver.)
public class App extends SimpleApplication {
// A real application wouldn't just dump these in the SimpleApplication. This is a minimal example
Vector3f boxPosition = new Vector3f(0,0,0);
Vector3f boxVelocity = new Vector3f(0,0,0);
Geometry boxGeometry;
public boolean hasBeenInitialised = false;
public static void main(String[] args){
App app = new App();
app.start(); // start the game
}
public App(AppState... initialStates){
super(initialStates);
}
@Override
public void simpleUpdate(float tpf){
super.simpleUpdate(tpf);
boxPosition = boxPosition.add(boxVelocity.mult(tpf));
boxGeometry.setLocalTranslation(boxPosition);
}
public void setBoxVelocity(Vector3f boxVelocity){
this.boxVelocity = boxVelocity;
}
public Vector3f getBoxPosition(){
return boxPosition;
}
@Override
public void simpleInitApp() {
stateManager.attach(new TestDriver());
Box b = new Box(1, 1, 1);
boxGeometry = new Geometry("Box", b);
Material mat = new Material(assetManager,
"Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", ColorRGBA.Blue);
boxGeometry.setMaterial(mat);
rootNode.attachChild(boxGeometry);
hasBeenInitialised = true;
}
}
Thoughts
Even if your use of JUnit isn’t exactly the system tests I’m talking about I think its still very sensible to separate out the “script” that the test will describe and the “free wheeling” JMonkey application
There’s no reason TestDriver couldn’t be generic, and so be usable for the many applications you wish to script. I haven’t done this here to more clearly show the concept
Other considerations
What if the application crashes
For the purpose of brevity (and because I didn’t want to write it) I haven’t included some considerations, like what if the application crashes. As it stands that will lead to the test just timing out (or running forever if no timeout is set). What should actually happen is that the test should fail immediately. Thats all doable but requires special handling
Library?
I have been thinking this sort of approach could be rolled into a JMonkey testing library, but I’m trying not to have too many parallel projects.