diff --git a/docs/README.md b/docs/README.md index 62f11a13..5b2ffa18 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) \ No newline at end of file +- [Other Features](features/README.md) +- [Testing](testing/README.md) \ No newline at end of file diff --git a/docs/testing/1-setup.md b/docs/testing/1-setup.md new file mode 100644 index 00000000..c1613613 --- /dev/null +++ b/docs/testing/1-setup.md @@ -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) diff --git a/docs/testing/2-controllers.md b/docs/testing/2-controllers.md new file mode 100644 index 00000000..33a9fc62 --- /dev/null +++ b/docs/testing/2-controllers.md @@ -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) \ No newline at end of file diff --git a/docs/testing/3-subcomponents.md b/docs/testing/3-subcomponents.md new file mode 100644 index 00000000..14a1b8a1 --- /dev/null +++ b/docs/testing/3-subcomponents.md @@ -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) \ No newline at end of file diff --git a/docs/testing/4-dagger.md b/docs/testing/4-dagger.md new file mode 100644 index 00000000..95a4715c --- /dev/null +++ b/docs/testing/4-dagger.md @@ -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) \ No newline at end of file diff --git a/docs/testing/README.md b/docs/testing/README.md new file mode 100644 index 00000000..c1e28f92 --- /dev/null +++ b/docs/testing/README.md @@ -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) \ No newline at end of file diff --git a/ludo/build.gradle b/ludo/build.gradle index 46f592bb..451e040d 100644 --- a/ludo/build.gradle +++ b/ludo/build.gradle @@ -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 { diff --git a/ludo/src/main/java/de/uniks/ludo/controller/IngameController.java b/ludo/src/main/java/de/uniks/ludo/controller/IngameController.java index 62c84337..6982357d 100644 --- a/ludo/src/main/java/de/uniks/ludo/controller/IngameController.java +++ b/ludo/src/main/java/de/uniks/ludo/controller/IngameController.java @@ -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; diff --git a/ludo/src/main/java/de/uniks/ludo/controller/sub/DiceSubComponent.java b/ludo/src/main/java/de/uniks/ludo/controller/sub/DiceSubComponent.java index 79dab483..d31ca7f2 100644 --- a/ludo/src/main/java/de/uniks/ludo/controller/sub/DiceSubComponent.java +++ b/ludo/src/main/java/de/uniks/ludo/controller/sub/DiceSubComponent.java @@ -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); diff --git a/ludo/src/test/java/de/uniks/ludo/controller/IngameControllerTest.java b/ludo/src/test/java/de/uniks/ludo/controller/IngameControllerTest.java index 6959a2ec..95741be2 100644 --- a/ludo/src/test/java/de/uniks/ludo/controller/IngameControllerTest.java +++ b/ludo/src/test/java/de/uniks/ludo/controller/IngameControllerTest.java @@ -35,7 +35,7 @@ public class IngameControllerTest extends ControllerTest { GameService gameService; @Spy Subscriber subscriber; - @Spy + @InjectMocks DiceSubComponent diceSubComponent; @InjectMocks @@ -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)); }