Skip to content

Commit

Permalink
Add documentation about tests (#98)
Browse files Browse the repository at this point in the history
* docs(testing): Add main section and subsections for controllers and setup
* fix(ludo): Remove unused code
* docs(testing): Add docs about testing subcomponents
* docs(testing): Little tweaks and changes
* docs(testing): Add section about Dagger
* docs(testing): Tweaks descriptions
* docs(testing): Apply suggested changes

---------

Co-authored-by: Adrian Kunz <[email protected]>
  • Loading branch information
LeStegii and Clashsoft authored May 16, 2024
1 parent 2f9dd59 commit 2901ba6
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 4 deletions.
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ More detailed information can be found in the categories below.

- [Controllers and Components](controller/README.md)
- [Tutorial](tutorial/how-to-start.md)
- [Other Features](features/README.md)
- [Other Features](features/README.md)
- [Testing](testing/README.md)
47 changes: 47 additions & 0 deletions docs/testing/1-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Setup

In order to properly test your application, it is recommended to use [TestFX](https://github.com/TestFX/TestFX) alongside [Mockito](https://github.com/mockito/mockito).
For a full explanation of both libraries, checkout their official documentation, as the following documentation will only cover a small part of what the projects have to offer.

## TestFX
TestFX can be used to test the frontend of your application by checking if certain requirements are met, for example view elements being visible or having a certain property.

Alongside TestFX, we also include Monocle which allows for headless testing without the app having to be open on your screen every time the tests run.

```groovy
testImplementation group: 'org.testfx', name: 'testfx-junit5', version: testFxVersion
testImplementation group: 'org.testfx', name: 'openjfx-monocle', version: monocleVersion
```

To enable headless testing, the following lines can be added to your `test` gradle task:

```groovy
test {
// ...
if (hasProperty('headless') || System.getenv('CI')) {
systemProperties = [
'java.awt.headless': 'true',
'testfx.robot' : 'glass',
'testfx.headless' : 'true',
'glass.platform' : 'Monocle',
'monocle.platform' : 'Headless',
'prism.order' : 'sw',
'prism.text' : 't2k',
]
}
}
```

Whenever the tests are ran with `CI=true`, headless mode will be enabled allowing for testing in CI environments like GitHub Actions.

## Mockito

Mockito is used to redefine certain methods in the code which currently aren't being tested but could influence the test results, for example by accessing an external API.

```groovy
testImplementation group: 'org.mockito', name: 'mockito-junit-jupiter', version: mockitoVersion
```

---

[Overview](README.md) | [Testing Controllers ➡](2-controllers)
95 changes: 95 additions & 0 deletions docs/testing/2-controllers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Testing Controllers

In the following section, you will learn how to test a basic controller using TestFX and Mockito.

## ControllerTest

Testing controllers using TestFX requires the test to extend from `ApplicationTest`.
It is however recommended to create a helper class like `ControllerTest` extending `ApplicationTest`.
This class will contain some common code to reduce the amount of boilerplate required for each controller test.

```java
public class ControllerTest extends ApplicationTest {

@Spy
protected App app = new App();
@Spy
protected ResourceBundle resources = ...; // Define common instances here and mock/spy them

protected Stage stage; // Useful for checking the title for example

@Override
public void start(Stage stage) throws Exception {
super.start(stage);
this.stage = stage;
app.start(stage);
stage.requestFocus(); // Make the test use the correct stage
}

@Override
public void stop() throws Exception {
super.stop();
app.stop();
app = null;
stage = null;
}
}
```

The main annotations offered by Mockito are `@Spy` and `@Mock`.
Mocking an instance completely removes all default behaviour and content of methods, fields and such, resulting in an empty shell which can later be redefined.
This is useful if the real behaviour isn't needed at all, but the instance itself has to exist.
Spying an instance doesn't touch the default behaviour but allows redefining parts of the logic and checking whether methods have been called using `verify`.

Spies and Mocks can later be injected into the controller instance which is being tested using `@InjectMocks`.

## Writing a real test

Since most of the setup is already defined in the `ControllerTest` class we can just extend it for our own tests.
In order to get Mockito working, the class has to be annotated with `@ExtendWith(MockitoExtension.class)`.

```java
@ExtendWith(MockitoExtension.class)
public class SetupControllerTest extends ControllerTest {

@InjectMocks
SetupController setupController;

@Override
public void start(Stage stage) throws Exception {
super.start(stage); // It is important to call super.start(stage) to setup the test correctly
app.show(setupController);
}

@Test
public void test() {
// Since we don't really want to show a different controller, we mock the show() method's behaviour to just return null
doReturn(null).when(app).show(any(), any());

assertEquals("Ludo - Set up the game", app.stage().getTitle());

// TestFX offers different methods for interacting with the application
moveTo("2");
moveBy(0, -20);
press(MouseButton.PRIMARY);
release(MouseButton.PRIMARY);
clickOn("#startButton");

waitForFxEvents(); // Wait for the logic to run

// Mockito can be used to check if the show() method was called with certain arguments
verify(app, times(1)).show("ingame", Map.of("playerAmount", 2));

}

}
```

Whenever something is loading asynchronously the method `waitForFxEvents()` should be called before checking the results.
This ensures that all JavaFX events have been run before continuing the tests.
Another way of waiting is the `sleep()` method, which allows to wait for a predefined time.
This is not recommended though as the defined time is either too long or too short and therefore can cause issues or unnecessary delays.

---

[⬅ Setup](1-setup.md) | [Overview](README.md) | [Testing SubComponents ➡](3-subcomponents.md)
39 changes: 39 additions & 0 deletions docs/testing/3-subcomponents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Testing SubComponents

As subcomponents extend from JavaFX nodes, mocking them destroys their functionality, which prevents them from being rendered and makes them useless.
Spying has similar issues.
Another problem with subcomponents is that they often require multiple dependencies like services themselves.

Therefore the best way of testing a subcomponent is by creating a field inside the controller test and annotating it with `@InjectMocks` so that all the dependencies are injected into it as well.
Since fields annotated with `@InjectMocks` cannot be injected into other fields annotated with the same annotation, this has to be done manually.

```java
@ExtendWith(MockitoExtension.class)
public class IngameControllerTest extends ControllerTest {

@Spy
GameService gameService;
@InjectMocks
DiceSubComponent diceSubComponent;
// ...

@InjectMocks
IngameController ingameController;

@Override
public void start(Stage stage) throws Exception {
super.start(stage);
ingameController.diceSubComponent = diceSubComponent; // Manually set the component instance
app.show(ingameController, Map.of("playerAmount", 2));
}

@Test
public void test() {
// ...
}
}
```

---

[⬅ Testing Controllers](2-controllers.md) | [Overview](README.md) | [Testing with Dagger ➡](4-dagger.md)
72 changes: 72 additions & 0 deletions docs/testing/4-dagger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Testing with Dagger

When using Dagger inside the application, testing the app requires a testcomponent to be present.
This component contains all the dependencies the main module provides, but modified in a way that doesn't require a connection for example.

The component itself can just extend the main component and then use modules to override certain dependencies.
Inside the modules, Mockito methods such as `spy()` and `mock()` can be used to create the required instances.
If specific behaviour is required, the instances can also be created manually.

```java
@Component(modules = {MainModule.class, TestModule.class})
@Singleton
public interface TestComponent extends MainComponent {

@Component.Builder
interface Builder extends MainComponent.Builder {
TestComponent build();
}
}
```

```java
@Module
public class TestModule {

@Provides
GameService gameService() {
return new GameService(new Random(42));
}

}
```

Now that the component and modules exist, we have to create a way of setting the component our app uses.
This step however is dependent on how the application is structured.
The easiest way is to create a setter method and call it, before the app starts.

```java
// ...
protected TestComponent testComponent;

@Override
public void start(Stage stage) throws Exception {
super.start(stage);
this.testComponent = (TestComponent) DaggerTestComponent.builder().mainApp(app).build();
app.setComponent(testComponent);
app.start(stage);
stage.requestFocus();
}

// ...
```

The component instance makes it possible to inject services from test classes e.g. AppTest to redefine their behavior.

```java
public class AppTest extends ControllerTest {
// ...

@BeforeEach
void setup() {
final AuthApiService authApiService = testComponent.authApiService();
// ...
}

// ...
}
```

---

[⬅ Testing SubComponents](3-subcomponents.md) | [Overview](README.md)
10 changes: 10 additions & 0 deletions docs/testing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Testing

There are plenty of ways to test different parts of your application.
This section covers the testing of controllers including view tests using TestFX and mocking using Mockito.
Since fulibFx uses Dagger internally and for example applications, the last subsection also contains some hints for working with dagger in tests.

1. [Setup](1-setup.md)
2. [Testing Controllers](2-controllers.md)
3. [Testing SubComponents](3-subcomponents.md)
4. [Testing with Dagger](4-dagger.md)
1 change: 1 addition & 0 deletions ludo/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
testImplementation group: 'org.testfx', name: 'openjfx-monocle', version: monocleVersion
testImplementation group: 'org.mockito', name: 'mockito-junit-jupiter', version: mockitoVersion
testAnnotationProcessor group: 'com.google.dagger', name: 'dagger-compiler', version: daggerVersion
testImplementation group: 'org.hamcrest', name: 'hamcrest', version: hamcrestVersion
}

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import javafx.scene.effect.BlurType;
import javafx.scene.effect.Shadow;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.AnchorPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class DiceSubComponent extends VBox {
public Label eyesLabel;

@Inject
public GameService gameService;
GameService gameService;

private final BooleanProperty enabled = new SimpleBooleanProperty(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class IngameControllerTest extends ControllerTest {
GameService gameService;
@Spy
Subscriber subscriber;
@Spy
@InjectMocks
DiceSubComponent diceSubComponent;

@InjectMocks
Expand All @@ -44,7 +44,7 @@ public class IngameControllerTest extends ControllerTest {
@Override
public void start(Stage stage) throws Exception {
super.start(stage);
diceSubComponent.gameService = gameService;
ingameController.diceSubComponent = diceSubComponent;
app.show(ingameController, Map.of("playerAmount", 2));
}

Expand Down

0 comments on commit 2901ba6

Please sign in to comment.