⚔ MonsterBattles Docs
Phuc PhamGitHub ↗
WEEK 07 · BACKEND

Persistence & Testing

Van tijdelijke opslag naar een database, van handmatige controles naar reproduceerbare tests. We sluiten MonsterBattles af met H2, Exposed, JUnit 5, Ktor-integratietests en debugging.

37 onderwerpenKotlin & KtorDatabase & tests

Wat bouwen we deze week?

De repository krijgt een H2/Exposed-implementatie. Daarna testen we de domeinregels, monster-CRUD en battle-API. De voorbeelden bouwen voort op de bestaande Monster, BattleService, DTO's en routes uit eerdere weken.

Codecontext: de codeblokken zijn gerichte voorbeelden voor een Kotlin/Ktor Amper-project. Ktor-, Exposed- en JUnit-fragmenten veronderstellen de bijbehorende dependencies, imports en bestaande classes. De getoonde output is waar nodig de verwachte werking, niet het resultaat van een volledige integratierun. Versies en database-API's moeten op elkaar zijn afgestemd.

De 37 onderwerpen

Bij ieder onderwerp vind je de uitleg, een codevoorbeeld, de verwachte uitkomst en de toepassing in MonsterBattles.

01Repository pattern

Een repository verbergt hoe monsters worden opgeslagen. De service vraagt domeinobjecten op via een interface.

Hoe werkt het? Een repository verbergt hoe monsters worden opgeslagen. De service vraagt domeinobjecten op via een interface.

Example.ktKOTLIN
interface MonsterRepository {
    fun findAll(): List<Monster>
    fun findById(id: Int): Monster?
    fun create(name: String, element: Element, stats: Stats): Monster
    fun update(id: Int, name: String, element: Element, stats: Stats): Monster?
    fun delete(id: Int): Boolean
}
▸ Terminal / outputVerwachte uitvoer
MonsterService gebruikt een contract, geen SQL.

MonsterBattles: De overstap van Map naar H2 verandert de service niet.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

02Persistence

Persistence betekent dat gegevens na het stoppen van de applicatie beschikbaar blijven. Opslag in een gewone List of Map is niet persistent.

Hoe werkt het? Persistence betekent dat gegevens na het stoppen van de applicatie beschikbaar blijven. Opslag in een gewone List of Map is niet persistent.

DatabaseConfig.ktKOTLIN
// Persistent database file (H2 JDBC URL):
val jdbcUrl = "jdbc:h2:file:./data/monsterbattles"
// Files remain on disk after the process exits.
▸ Terminal / outputVerwachte uitvoer
jdbc:h2:file:./data/monsterbattles

MonsterBattles: Monsters blijven bewaard na een herstart als ze in een databasebestand staan.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

03In-memory repository

Een in-memory repository slaat gegevens op in een gewone Kotlin-collectie. Hij is eenvoudig en snel, maar data verdwijnen bij een herstart.

Hoe werkt het? Een in-memory repository slaat gegevens op in een gewone Kotlin-collectie. Hij is eenvoudig en snel, maar data verdwijnen bij een herstart.

Example.ktKOTLIN
// De daadwerkelijke InMemoryMonsterRepository uit Week 04 implementeert
// de vijf methoden van MonsterRepository en beheert de ID-uitgifte.
val repository: MonsterRepository = InMemoryMonsterRepository()
val created = repository.create("Pyron", Element.FIRE, Stats(100, 30, 10, 110))
println(repository.findById(created.id)?.name)
▸ Terminal / outputVerwachte uitvoer
Pyron

MonsterBattles: Geschikt voor de eerste CRUD-versie en geïsoleerde tests.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

04In-memory database

Een in-memory database is een echte database die haar gegevens in RAM bewaart. Anders dan een Map ondersteunt H2 in-memory SQL, tabellen en transacties.

Hoe werkt het? Een in-memory database is een echte database die haar gegevens in RAM bewaart. Anders dan een Map ondersteunt H2 in-memory SQL, tabellen en transacties.

DatabaseConfig.ktKOTLIN
// H2 in-memory JDBC connection URL:
val url = "jdbc:h2:mem:monsterbattles;DB_CLOSE_DELAY=-1"
// SQL queries work, but data are not durable across process restarts.
▸ Terminal / outputVerwachte uitvoer
jdbc:h2:mem:monsterbattles;DB_CLOSE_DELAY=-1

MonsterBattles: Goed voor database-integratietests zonder permanent databasebestand.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

05H2

H2 is een relationele SQL-database die zowel in-memory als bestandsgebaseerd kan draaien. Je gebruikt JDBC en SQL om tabellen te beheren.

Hoe werkt het? H2 is een relationele SQL-database die zowel in-memory als bestandsgebaseerd kan draaien. Je gebruikt JDBC en SQL om tabellen te beheren.

DatabaseConfig.ktKOTLIN
import java.sql.DriverManager
DriverManager.getConnection("jdbc:h2:mem:monsters;DB_CLOSE_DELAY=-1", "sa", "").use { db ->
    db.createStatement().use { statement ->
        statement.executeUpdate("CREATE TABLE IF NOT EXISTS monsters (id INT PRIMARY KEY, name VARCHAR(80))")
    }
}
▸ Terminal / outputVerwachte uitvoer
Tabel monsters is aangemaakt.

MonsterBattles: Een lokale database is voldoende voor de MonsterBattles-baseline.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

06Exposed

Exposed is een Kotlin-bibliotheek voor databases. Met de DSL kun je tabellen en queries als Kotlin-code schrijven. De gekozen Exposed-versie bepaalt de exacte imports.

Hoe werkt het? Exposed is een Kotlin-bibliotheek voor databases. Met de DSL kun je tabellen en queries als Kotlin-code schrijven. De gekozen Exposed-versie bepaalt de exacte imports.

MonsterTable.ktKOTLIN
// In Exposed 0.61.0: IntIdTable defines a generated integer primary key.
object MonstersTable : IntIdTable("monsters") {
    val name = varchar("name", 80)
    val element = varchar("element", 16)
    val hp = integer("hp")
    val attack = integer("attack")
    val defense = integer("defense")
    val speed = integer("speed")
}
▸ Terminal / outputVerwachte uitvoer
Tabel monsters met id, name en hp.

MonsterBattles: MonsterRepository kan SQL-opslag aanbieden zonder SQL in Ktor-routes.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

07Database mapping

Database mapping vertaalt een rij uit de database naar een domeinobject. Het domein hoeft de tabellen of Exposed-klassen niet te kennen.

Hoe werkt het? Database mapping vertaalt een rij uit de database naar een domeinobject. Het domein hoeft de tabellen of Exposed-klassen niet te kennen.

Example.ktKOTLIN
// Mapping in ExposedMonsterRepository, using the existing Week 02 model.
fun ResultRow.toMonster(): Monster = Monster(
    id = this[MonstersTable.id].value,
    name = this[MonstersTable.name],
    element = Element.valueOf(this[MonstersTable.element]),
    stats = Stats(
        this[MonstersTable.hp], this[MonstersTable.attack],
        this[MonstersTable.defense], this[MonstersTable.speed]
    )
)
▸ Terminal / outputVerwachte uitvoer
Monster(id=1, name=Pyron, hp=100)

MonsterBattles: Je kunt de databasekolommen aanpassen zonder overal de businesslogica aan te passen.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

08Seeddata

Seeddata zijn voorspelbare startgegevens. Een seedproces moet bij opnieuw opstarten geen dubbele monsters aanmaken.

Hoe werkt het? Seeddata zijn voorspelbare startgegevens. Een seedproces moet bij opnieuw opstarten geen dubbele monsters aanmaken.

SeedData.ktKOTLIN
// Use the Week 04 create() method or an idempotent insert if absent.
val pyron = Monster(1, "Pyron", Element.FIRE, Stats(100, 30, 10, 110))
val aquara = Monster(2, "Aquara", Element.WATER, Stats(110, 25, 12, 95))
// Seed only when the corresponding persistent records do not exist.
▸ Terminal / outputVerwachte uitvoer
2 vaste monsters voor de demo.

MonsterBattles: Pyron en Aquara zijn direct beschikbaar voor API-voorbeelden en battles.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

09Unit testing

Een unit test controleert één kleine eenheid, zoals calculateDamage. Externe systemen vervang je waar nodig door eenvoudige fakes.

Hoe werkt het? Een unit test controleert één kleine eenheid, zoals calculateDamage. Externe systemen vervang je waar nodig door eenvoudige fakes.

DamageTest.ktKOTLIN
import kotlin.test.Test
import kotlin.test.assertEquals
fun calculateDamage(attack: Int, defense: Int) = maxOf(1, attack - defense)
class DamageTest {
    @Test fun minimumDamage() {
        assertEquals(1, calculateDamage(10, 25))
    }
}
▸ Terminal / outputVerwachte uitvoer
1 test geslaagd.

MonsterBattles: De formule moet minimaal één damage opleveren en geen negatieve waarde.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

10Integration testing

Een integratietest controleert de samenwerking tussen meerdere onderdelen, bijvoorbeeld Ktor-route, service en repository.

Hoe werkt het? Een integratietest controleert de samenwerking tussen meerdere onderdelen, bijvoorbeeld Ktor-route, service en repository.

MonsterRoutesTest.ktKOTLIN
testApplication {
    application { module() }
    val response = client.get("/monsters")
    assertEquals(HttpStatusCode.OK, response.status)
}
▸ Terminal / outputVerwachte uitvoer
GET /monsters → 200 OK

MonsterBattles: Je ziet of POST /monsters echt door de lagen heen werkt.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

11API-contracttests

Een API-contract legt method, route, JSON-velden en statuscodes vast. Contracttests beschermen clients tegen onverwachte wijzigingen.

Hoe werkt het? Een API-contract legt method, route, JSON-velden en statuscodes vast. Contracttests beschermen clients tegen onverwachte wijzigingen.

MonsterApiTest.ktKOTLIN
val response = client.get("/monsters/1")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("\"name\":\"Pyron\""))
▸ Terminal / outputVerwachte uitvoer
200 OK met name=Pyron.

MonsterBattles: Een monster met ID 1 heeft stabiele velden in de GET-response.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

12Testing pyramid

De testpiramide bevat veel snelle unit tests, een kleiner aantal integratietests en enkele end-to-end tests. Het is een afweging, geen verplicht aantal.

Hoe werkt het? De testpiramide bevat veel snelle unit tests, een kleiner aantal integratietests en enkele end-to-end tests. Het is een afweging, geen verplicht aantal.

TestingPyramid.txtTEXT
      / End-to-end \
     / Integration \
    /  Unit tests   \
▸ Terminal / outputVerwachte uitvoer
Unit > integration > end-to-end als praktische richtlijn.

MonsterBattles: Test de damageformule vaak en de hele HTTP-flow gericht.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

13kotlin.test

kotlin.test levert multiplatform testfuncties en assertions. Op de JVM draait het vaak met een engine zoals JUnit 5.

Hoe werkt het? kotlin.test levert multiplatform testfuncties en assertions. Op de JVM draait het vaak met een engine zoals JUnit 5.

DamageTest.ktKOTLIN
import kotlin.test.assertEquals
import kotlin.test.assertTrue
val damage = maxOf(1, 30 - 12)
assertEquals(18, damage)
assertTrue(damage > 0)
▸ Terminal / outputVerwachte uitvoer
Assertions slagen.

MonsterBattles: Damage- en battlelogica zijn eenvoudig te controleren.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

14JUnit 5

JUnit Jupiter is het programmeermodel van JUnit 5. Configureer JUnit 5 en de benodigde testdependencies in Amper module.yaml.

Hoe werkt het? JUnit Jupiter is het programmeermodel van JUnit 5. Configureer JUnit 5 en de benodigde testdependencies in Amper module.yaml.

module.yamlYAML
test-dependencies:
  - $ktor.server.testHost
  - org.junit.jupiter:junit-jupiter-api:5.11.4
  - org.junit.jupiter:junit-jupiter-engine:5.11.4
  - org.junit.jupiter:junit-jupiter-params:5.11.4
▸ Terminal / outputVerwachte uitvoer
Start tests via ./kotlin test (Amper). Controleer de gekozen versies en testrunner.

MonsterBattles: Alle domein- en API-tests draaien met dezelfde test runner.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

15@Test

Met @Test markeer je een methode die de testrunner moet uitvoeren. Geef testnamen betekenis zodat mislukkingen duidelijk zijn.

Hoe werkt het? Met @Test markeer je een methode die de testrunner moet uitvoeren. Geef testnamen betekenis zodat mislukkingen duidelijk zijn.

DamageTest.ktKOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
class DamageTest {
    @Test fun `damage has minimum one`() {
        assertEquals(1, maxOf(1, 5 - 10))
    }
}
▸ Terminal / outputVerwachte uitvoer
1 test succesvol.

MonsterBattles: Een test verifieert een battle-regel zonder handmatige controle.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

16@DisplayName

Met @DisplayName krijgt een test een leesbare naam in de testresultaten. De functienaam blijft de programmatische naam.

Hoe werkt het? Met @DisplayName krijgt een test een leesbare naam in de testresultaten. De functienaam blijft de programmatische naam.

DamageTest.ktKOTLIN
@Test
@DisplayName("Minimum damage is always one")
fun minimumDamage() {
    assertEquals(1, maxOf(1, 5 - 10))
}
▸ Terminal / outputVerwachte uitvoer
Minimum damage is always one ✓

MonsterBattles: Rapporten maken duidelijk welke MonsterBattles-regel getest is.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

17Assertions

Assertions vergelijken werkelijke en verwachte uitkomsten. assertEquals vergelijkt waarden; assertTrue een conditie; assertFailsWith verwacht een exception.

Hoe werkt het? Assertions vergelijken werkelijke en verwachte uitkomsten. assertEquals vergelijkt waarden; assertTrue een conditie; assertFailsWith verwacht een exception.

AssertionsTest.ktKOTLIN
import kotlin.test.*
assertEquals(18, maxOf(1, 30 - 12))
assertTrue(18 > 0)
assertFailsWith<IllegalArgumentException> {
    require(-1 >= 0) { "HP must be positive" }
}
▸ Terminal / outputVerwachte uitvoer
Alle drie assertions slagen.

MonsterBattles: Je kunt damage, winner, validatie en HP-grenzen controleren.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

18Arrange-Act-Assert

AAA deelt tests in drie fasen: maak testdata klaar, voer de actie uit en vergelijk de uitkomst.

Hoe werkt het? AAA deelt tests in drie fasen: maak testdata klaar, voer de actie uit en vergelijk de uitkomst.

DamageTest.ktKOTLIN
// Arrange
val attack = 30
val defense = 12
// Act
val damage = maxOf(1, attack - defense)
// Assert
assertEquals(18, damage)
▸ Terminal / outputVerwachte uitvoer
18 damage, test slaagt.

MonsterBattles: Tests worden gemakkelijk te lezen en te onderhouden.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

19Parameterized tests

Een parameterized test herhaalt dezelfde testlogica met verschillende inputs en verwachte uitkomsten.

Hoe werkt het? Een parameterized test herhaalt dezelfde testlogica met verschillende inputs en verwachte uitkomsten.

DamageParameterizedTest.ktKOTLIN
@ParameterizedTest
@CsvSource("30, 12, 18", "10, 50, 1", "20, 20, 1")
fun damageCases(attack: Int, defense: Int, expected: Int) {
    assertEquals(expected, maxOf(1, attack - defense))
}
▸ Terminal / outputVerwachte uitvoer
3 parametrische testgevallen slagen.

MonsterBattles: Controleer normale damage en de minimumdamage zonder dubbel testwerk.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

20@ParameterizedTest

@ParameterizedTest van JUnit Jupiter markeert een test die argumenten uit een bron ontvangt. Hiervoor is de parameterized-testmodule nodig.

Hoe werkt het? @ParameterizedTest van JUnit Jupiter markeert een test die argumenten uit een bron ontvangt. Hiervoor is de parameterized-testmodule nodig.

HpParameterizedTest.ktKOTLIN
@ParameterizedTest
@ValueSource(ints = [0, 1, 50, 100])
fun hpCannotBecomeNegative(damage: Int) {
    assertTrue(maxOf(0, 40 - damage) >= 0)
}
▸ Terminal / outputVerwachte uitvoer
4 uitvoeringen van dezelfde test.

MonsterBattles: Eén test kan verschillende combinaties van Attack en Defense afdekken.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

21@MethodSource

@MethodSource gebruikt een methode als bron voor de argumenten. Handig wanneer een test meerdere complexe objecten nodig heeft.

Hoe werkt het? @MethodSource gebruikt een methode als bron voor de argumenten. Handig wanneer een test meerdere complexe objecten nodig heeft.

DamageSourceTest.ktKOTLIN
companion object {
    @JvmStatic
    fun cases() = listOf(
        Arguments.of(30, 12, 18),
        Arguments.of(10, 20, 1)
    ).stream()
}
@ParameterizedTest
@MethodSource("cases")
fun damageCases(attack: Int, defense: Int, expected: Int) {
    assertEquals(expected, maxOf(1, attack - defense))
}
▸ Terminal / outputVerwachte uitvoer
2 testgevallen uit de method source.

MonsterBattles: Test verschillende monstertypen met een gezamenlijke damagefunctie.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

22@JvmStatic

@JvmStatic maakt een Kotlin companion-objectfunctie beschikbaar als statische Java-methode. JUnit kan zo een niet-instantiemethode als MethodSource gebruiken.

Hoe werkt het? @JvmStatic maakt een Kotlin companion-objectfunctie beschikbaar als statische Java-methode. JUnit kan zo een niet-instantiemethode als MethodSource gebruiken.

DamageSourceTest.ktKOTLIN
class DamageSourceTest {
    companion object {
        @JvmStatic fun names() = listOf("Pyron", "Aquara").stream()
    }
    @ParameterizedTest
    @MethodSource("names")
    fun nonEmptyName(name: String) = assertTrue(name.isNotBlank())
}
▸ Terminal / outputVerwachte uitvoer
2 testgevallen met namen.

MonsterBattles: Bronfuncties voor testdata worden eenvoudig door JUnit gevonden.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

23Test lifecycle

JUnit heeft hooks voor initialisatie en opruimen: @BeforeEach, @AfterEach, @BeforeAll, @AfterAll. Gebruik ze om tests onafhankelijk te houden.

Hoe werkt het? JUnit heeft hooks voor initialisatie en opruimen: @BeforeEach, @AfterEach, @BeforeAll, @AfterAll. Gebruik ze om tests onafhankelijk te houden.

MonsterServiceTest.ktKOTLIN
@BeforeEach
fun setUp() { repository = InMemoryMonsterRepository() }
@AfterEach
fun tearDown() { repository.clear() }
// @BeforeAll / @AfterAll are used for class-wide setup and cleanup.
▸ Terminal / outputVerwachte uitvoer
Voor elke test een schone repository.

MonsterBattles: Elke test start met dezelfde monsters, zonder data van een vorige test.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

24Mock en Fake

Een fake werkt eenvoudig echt gedrag na, zoals opslaan in een Map. Een mock registreert of verwachte calls plaatsvinden. Niet elke test heeft een mocklibrary nodig.

Hoe werkt het? Een fake werkt eenvoudig echt gedrag na, zoals opslaan in een Map. Een mock registreert of verwachte calls plaatsvinden. Niet elke test heeft een mocklibrary nodig.

FakeRepository.ktKOTLIN
// A fake must implement the COMPLETE MonsterRepository interface.
class FakeRepository(private val delegate: MonsterRepository) : MonsterRepository by delegate
// Supply an InMemoryMonsterRepository, never a second Monster model.
▸ Terminal / outputVerwachte uitvoer
FakeRepository.findById(1) → Pyron.

MonsterBattles: Een fake laat je BattleService testen zonder H2.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

25Happy paths

Happy-path-tests gebruiken geldige input en verwachten een normaal resultaat. Deze tests tonen wat de API standaard moet kunnen.

Hoe werkt het? Happy-path-tests gebruiken geldige input en verwachten een normaal resultaat. Deze tests tonen wat de API standaard moet kunnen.

MonsterApiTest.ktKOTLIN
val response = client.get("/monsters")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Pyron"))
▸ Terminal / outputVerwachte uitvoer
200 OK met Pyron.

MonsterBattles: Controleer dat GET /monsters een JSON-lijst met monsters oplevert.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

26Edge cases

Edge cases zijn grenswaarden: nul HP, gelijke Speed, een heel hoge Defense en een lege monsterlijst. Ze vinden bugs die gewone voorbeelden niet tonen.

Hoe werkt het? Edge cases zijn grenswaarden: nul HP, gelijke Speed, een heel hoge Defense en een lege monsterlijst. Ze vinden bugs die gewone voorbeelden niet tonen.

BattleEdgeTest.ktKOTLIN
val attackerSpeed = 100
val defenderSpeed = 100
val attackerStarts = attackerSpeed >= defenderSpeed
assertTrue(attackerStarts)
assertEquals(0, maxOf(0, 3 - 99))
▸ Terminal / outputVerwachte uitvoer
Gelijke Speed → eerste monster; HP = 0.

MonsterBattles: Bij gelijke Speed begint het eerste monster uit het request.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

27Error cases

Error-case-tests controleren welke foutmelding of statuscode bij ongeldige input hoort. Verwacht geen succesresponse bij onjuiste parameters.

Hoe werkt het? Error-case-tests controleren welke foutmelding of statuscode bij ongeldige input hoort. Verwacht geen succesresponse bij onjuiste parameters.

ErrorApiTest.ktKOTLIN
val response = client.get("/monsters/999")
assertEquals(HttpStatusCode.NotFound, response.status)
// POST /battles with the same IDs should return 400.
▸ Terminal / outputVerwachte uitvoer
404 Not Found.

MonsterBattles: Een onbekend monster moet 404 geven; twee identieke IDs 400.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

28Debugging

Debugging is het systematisch zoeken naar de oorzaak van verkeerd gedrag. Zet de debugger in bij opvallende testresultaten en controleer de actuele toestand.

Hoe werkt het? Debugging is het systematisch zoeken naar de oorzaak van verkeerd gedrag. Zet de debugger in bij opvallende testresultaten en controleer de actuele toestand.

BattleService.ktKOTLIN
// Week 02 defines the canonical model with stats.speed.
val first = if (pyron.stats.speed >= aquara.stats.speed) pyron else aquara
println(first.name)
▸ Terminal / outputVerwachte uitvoer
Pyron

MonsterBattles: Onderzoek waarom een battle een verkeerde winnaar kiest.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

29Breakpoints

Een breakpoint onderbreekt de uitvoering vóór een instructie. Met Step Into, Step Over en Resume volg je de call stack.

Hoe werkt het? Een breakpoint onderbreekt de uitvoering vóór een instructie. Met Step Into, Step Over en Resume volg je de call stack.

BattleService.ktKOTLIN
// Inspect the temporary HP variables inside BattleService.fight().
val damage = DamageCalculator.calculate(source.stats.attack, target.stats.defense)
val remaining = maxOf(0, hp.getValue(target.id) - damage)
hp[target.id] = remaining
▸ Terminal / outputVerwachte uitvoer
Debugger pauzeert vóór HP-aanpassing.

MonsterBattles: Zet een breakpoint waar currentHp wordt verminderd tijdens een beurt.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

30Variables inspecteren

Met de Variables-view kun je lokale variabelen en objecten inspecteren. Watches laten uitdrukkingen herhaald evalueren tijdens het debuggen.

Hoe werkt het? Met de Variables-view kun je lokale variabelen en objecten inspecteren. Watches laten uitdrukkingen herhaald evalueren tijdens het debuggen.

BattleService.ktKOTLIN
// Inspect in the debugger:
// attackerHp = 100
// defenderHp = 110
// damage = 18
// after update, defenderHp = 92
▸ Terminal / outputVerwachte uitvoer
defenderHp = 92

MonsterBattles: Controleer attackerHp, defenderHp, damage en attackerTurn na elke beurt.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

31Testresultaten analyseren

Een falende test bevat een verwachte waarde, een werkelijke waarde en een stacktrace. Lees eerst de assertion-fout en zoek daarna de verantwoordelijke code.

Hoe werkt het? Een falende test bevat een verwachte waarde, een werkelijke waarde en een stacktrace. Lees eerst de assertion-fout en zoek daarna de verantwoordelijke code.

TestReport.txtTEXT
// Test failure example:
// Expected: 18
// Actual:   17
// at DamageTest.damageSubtractsDefense(DamageTest.kt:12)
▸ Terminal / outputVerwachte uitvoer
Zoek fout in damageberekening of testdata.

MonsterBattles: Bij 17 in plaats van 18 damage controleer je Attack, Defense en formule.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

32CRUD integration tests

Test alle CRUD-operaties via echte Ktor-requests. Controleer naast statuscodes ook de inhoud en het verwijderen van data.

Hoe werkt het? Test alle CRUD-operaties via echte Ktor-requests. Controleer naast statuscodes ook de inhoud en het verwijderen van data.

MonsterCrudTest.ktKOTLIN
val create = client.post("/monsters") {
    contentType(ContentType.Application.Json)
    setBody("""{"name":"Zephyra","element":"WIND","hp":90,"attack":20,"defense":8,"speed":120}""")
}
assertEquals(HttpStatusCode.Created, create.status)
// Follow with GET, PUT and DELETE against the returned ID.
▸ Terminal / outputVerwachte uitvoer
201 Created, daarna ophalen/wijzigen/verwijderen.

MonsterBattles: POST, GET, PUT en DELETE werken volgens één API-contract.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

33Battle integration tests

Deze tests sturen POST /battles naar de Ktor-route en controleren JSON-resultaat, winnaar en battlelog. Zo test je de hele HTTP-flow.

Hoe werkt het? Deze tests sturen POST /battles naar de Ktor-route en controleren JSON-resultaat, winnaar en battlelog. Zo test je de hele HTTP-flow.

BattleRoutesTest.ktKOTLIN
val response = client.post("/battles") {
    contentType(ContentType.Application.Json)
    setBody("""{"attackerId":1,"defenderId":2}""")
}
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("winnerId"))
▸ Terminal / outputVerwachte uitvoer
200 OK + winnerId en log.

MonsterBattles: Met Pyron en Aquara eindigt de battle met een winnaar en niet-lege log.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

34Deployment

Deployment betekent dat je de server buiten IntelliJ laat draaien. Met JetBrains Amper start en test je de JVM-app vanuit het project.

Hoe werkt het? Deployment betekent dat je de server buiten IntelliJ laat draaien. Met JetBrains Amper start en test je de JVM-app vanuit het project.

Terminal.txtTEXT
./kotlin build
./kotlin run
# Configure host, port and database path for deployment.
▸ Terminal / outputVerwachte uitvoer
Ktor server gestart op ingestelde poort.

MonsterBattles: De MonsterBattles-backend is ook vanaf een andere client aan te roepen.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

35Installatie en gebruik

Leg vast welke JDK, configuratie en opdrachten nodig zijn. De database-URL en poort moeten overeenkomen met de runtimeconfiguratie.

Hoe werkt het? Leg vast welke JDK, configuratie en opdrachten nodig zijn. De database-URL en poort moeten overeenkomen met de runtimeconfiguratie.

Terminal.txtTEXT
./kotlin test
./kotlin run
curl http://localhost:8080/monsters
# Windows: kotlin.bat test / kotlin.bat run
▸ Terminal / outputVerwachte uitvoer
GET /monsters → JSON met monsters.

MonsterBattles: Een andere ontwikkelaar kan de server lokaal opstarten en de API testen.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

36Eindarchitectuur

Alle onderdelen werken samen: Ktor-route, DTO, service, domein, repository en H2. Tests controleren de domeinregels en de API.

Hoe werkt het? Alle onderdelen werken samen: Ktor-route, DTO, service, domein, repository en H2. Tests controleren de domeinregels en de API.

ProjectStructure.txtTEXT
src/nl/monsterbattles/api/
  Application.kt
  common/ApiError.kt
  config/Plugins.kt, DatabaseFactory.kt
  features/monsters/model/{Monster,Stats,Element}.kt
  features/monsters/repository/{MonsterRepository,InMemoryMonsterRepository,ExposedMonsterRepository,MonstersTable}.kt
  features/monsters/{MonsterService,MonsterRoutes}.kt
  features/battles/model/{BattleEvent,BattleResult}.kt
  features/battles/dto/BattleDtos.kt
  features/battles/{DamageCalculator,BattleService,BattleRoutes}.kt
resources/application.yaml
resources/logback.xml
test/nl/monsterbattles/api/
▸ Terminal / outputVerwachte uitvoer
Herkenbare Kotlin/Ktor-projectstructuur.

MonsterBattles: MonsterBattles heeft een duidelijke grens tussen HTTP, spelregels en opslag.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

37API-documentatie

De API-documentatie beschrijft per endpoint de methode, parameters, body, responses en fouten. Een HTTP-client kan daarmee requests reproduceren.

Hoe werkt het? De API-documentatie beschrijft per endpoint de methode, parameters, body, responses en fouten. Een HTTP-client kan daarmee requests reproduceren.

ApiContract.httpTEXT
GET    /monsters                 → 200
GET    /monsters/{id}            → 200 / 404
POST   /monsters                 → 201 / 400
PUT    /monsters/{id}            → 200 / 400 / 404
DELETE /monsters/{id}            → 204 / 404
POST   /battles                  → 200 / 400 / 404
▸ Terminal / outputVerwachte uitvoer
Zes routes met expliciete verwachte statussen.

MonsterBattles: De monster-CRUD en battle-endpoint vormen het vaste clientcontract.

Wanneer kies je een alternatief? Dit is een afzonderlijk leeronderwerp. Voor de minimale MonsterBattles-backend gebruiken we alleen deze techniek wanneer die bij het vaste domeinmodel en het Amper-project past.

Geïntegreerd voorbeeld: H2 / Exposed en tests

We vervangen uitsluitend de repositoryimplementatie. Het MonsterRepository-contract, het domeinmodel, de monster-CRUD, BattleService en de API-DTO's blijven identiek. De volgende bestanden zijn contextafhankelijke projectcode; hun werking is pas bewezen nadat de volledige Amper-applicatie daadwerkelijk is gebouwd en getest.

config/DatabaseFactory.ktKOTLIN
package nl.monsterbattles.api.config

import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import nl.monsterbattles.api.features.monsters.repository.MonstersTable

object DatabaseFactory {
    fun connect(url: String = "jdbc:h2:file:./data/monsterbattles"): Database {
        val db = Database.connect(url, driver = "org.h2.Driver", user = "sa", password = "")
        transaction(db) { SchemaUtils.create(MonstersTable) }
        return db
    }
}
features/monsters/repository/MonstersTable.ktKOTLIN
package nl.monsterbattles.api.features.monsters.repository

import org.jetbrains.exposed.dao.id.IntIdTable

object MonstersTable : IntIdTable("monsters") {
    val name = varchar("name", 80)
    val element = varchar("element", 16)
    val hp = integer("hp")
    val attack = integer("attack")
    val defense = integer("defense")
    val speed = integer("speed")
}
features/monsters/repository/ExposedMonsterRepository.ktKOTLIN
package nl.monsterbattles.api.features.monsters.repository

import nl.monsterbattles.api.features.monsters.model.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.transaction

class ExposedMonsterRepository(private val db: Database) : MonsterRepository {
    private fun ResultRow.toDomain() = Monster(
        this[MonstersTable.id].value, this[MonstersTable.name],
        Element.valueOf(this[MonstersTable.element]),
        Stats(this[MonstersTable.hp], this[MonstersTable.attack],
              this[MonstersTable.defense], this[MonstersTable.speed])
    )

    override fun findAll(): List<Monster> = transaction(db) {
        MonstersTable.selectAll().map { it.toDomain() }
    }
    override fun findById(id: Int): Monster? = transaction(db) {
        MonstersTable.selectAll().where { MonstersTable.id eq id }
            .singleOrNull()?.toDomain()
    }
    override fun create(name: String, element: Element, stats: Stats): Monster = transaction(db) {
        val id = MonstersTable.insertAndGetId {
            it[MonstersTable.name] = name
            it[MonstersTable.element] = element.name
            it[hp] = stats.hp; it[attack] = stats.attack
            it[defense] = stats.defense; it[speed] = stats.speed
        }.value
        Monster(id, name, element, stats)
    }
    override fun update(id: Int, name: String, element: Element, stats: Stats): Monster? = transaction(db) {
        val changed = MonstersTable.update({ MonstersTable.id eq id }) {
            it[MonstersTable.name] = name
            it[MonstersTable.element] = element.name
            it[hp] = stats.hp; it[attack] = stats.attack
            it[defense] = stats.defense; it[speed] = stats.speed
        }
        if (changed == 0) null else Monster(id, name, element, stats)
    }
    override fun delete(id: Int): Boolean = transaction(db) {
        MonstersTable.deleteWhere { MonstersTable.id eq id } > 0
    }
}

Omdat Exposed hier JDBC gebruikt, moet de applicatie rekening houden met blocking I/O. Bij Ktor-coroutines hoort een passende dispatcher of een expliciete uitvoeringsstrategie. Dat is een extra integratiecontrole en niet al bewezen door deze snippets.

Application.kt — compositiefragmentKOTLIN
// Replace only the concrete repository from Week 04.
val db = DatabaseFactory.connect()
val repository: MonsterRepository = ExposedMonsterRepository(db)
val monsterService = MonsterService(repository)
val battleService = BattleService(repository)
// Keep the existing configurePlugins(), monsterRoutes() and battleRoutes().
AmperCommands.txtTEXT
./kotlin run
./kotlin test
# Windows: kotlin.bat run / kotlin.bat test
▸ Terminal / outputVerwachte uitvoer
Verwachte handelingen, nog niet uitgevoerd in deze documentatierevisie.

Tests gebruiken een afzonderlijke H2 in-memory database. Test minimaal: normale/minimumdamage; Pyron wint na 7 rondes en 13 turns; gelijke Speed; POST/GET; PUT/DELETE; en POST /battles plus ongeldige input. Voor file-persistentie test je met een nieuwe databaseverbinding naar hetzelfde bestand.

Testoverzicht

Deze voorbeelden tonen hoe je meerdere soorten fouten detecteert. Gebruik vaste seeddata en reset toestand per test zodat de testvolgorde geen invloed heeft op de uitslag.

TestSoortVerwachting
Normale damageUnit30 − 12 = 18
MinimumdamageUnitMinimaal 1
Gelijke SpeedUnitEerst geselecteerde begint
HP-grensUnitHP niet onder 0
Monster aanmaken en ophalenIntegratie201 / 200
Monster wijzigenIntegratie200
Monster verwijderenIntegratie204
Battle startenIntegratie200 + winnaar + log
Onbekende monster-IDIntegratie404
Ongeldige battle-inputIntegratie400

Cheat Sheet

BegripGebruik
RepositoryInterface voor dataopslag
In-memory / persistentRAM / opslag die procesherstart overleeft
H2Relationele database voor lokale ontwikkeling
Exposed DSLTabellen en SQL-queries met Kotlin
SeeddataVoorspelbare startwaarden
Unit / integratieGeïsoleerde logica / samenwerking tussen onderdelen
@TestEen test markeren
@DisplayNameLeesbare testnaam
assertEqualsWaarden vergelijken
assertFailsWithVerwachte exception testen
AAAArrange, Act, Assert
@ParameterizedTestDezelfde test met meerdere inputs
@MethodSourceParameters uit een methode
@JvmStaticCompanion-methode als statische JVM-methode aanbieden
Lifecycle hooksTests voorbereiden en opruimen
Fake / mockEenvoudige implementatie / interacties verifiëren
testApplicationKtor API testen zonder externe server
BreakpointsUitvoering onderbreken voor inspectie
./kotlin testTest-suite uitvoeren
MonsterBattles Docs · Code, comments & output