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.
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.
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.
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
}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.
// Persistent database file (H2 JDBC URL):
val jdbcUrl = "jdbc:h2:file:./data/monsterbattles"
// Files remain on disk after the process exits.jdbc:h2:file:./data/monsterbattlesMonsterBattles: 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.
// 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)PyronMonsterBattles: 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.
// 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.jdbc:h2:mem:monsterbattles;DB_CLOSE_DELAY=-1MonsterBattles: 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.
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))")
}
}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.
// 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")
}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.
// 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]
)
)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.
// 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.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.
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))
}
}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.
testApplication {
application { module() }
val response = client.get("/monsters")
assertEquals(HttpStatusCode.OK, response.status)
}GET /monsters → 200 OKMonsterBattles: 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.
val response = client.get("/monsters/1")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("\"name\":\"Pyron\""))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.
/ End-to-end \
/ Integration \
/ Unit tests \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.
import kotlin.test.assertEquals
import kotlin.test.assertTrue
val damage = maxOf(1, 30 - 12)
assertEquals(18, damage)
assertTrue(damage > 0)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.
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.4Start 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.
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))
}
}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.
@Test
@DisplayName("Minimum damage is always one")
fun minimumDamage() {
assertEquals(1, maxOf(1, 5 - 10))
}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.
import kotlin.test.*
assertEquals(18, maxOf(1, 30 - 12))
assertTrue(18 > 0)
assertFailsWith<IllegalArgumentException> {
require(-1 >= 0) { "HP must be positive" }
}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.
// Arrange
val attack = 30
val defense = 12
// Act
val damage = maxOf(1, attack - defense)
// Assert
assertEquals(18, damage)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.
@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))
}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.
@ParameterizedTest
@ValueSource(ints = [0, 1, 50, 100])
fun hpCannotBecomeNegative(damage: Int) {
assertTrue(maxOf(0, 40 - damage) >= 0)
}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.
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))
}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.
class DamageSourceTest {
companion object {
@JvmStatic fun names() = listOf("Pyron", "Aquara").stream()
}
@ParameterizedTest
@MethodSource("names")
fun nonEmptyName(name: String) = assertTrue(name.isNotBlank())
}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.
@BeforeEach
fun setUp() { repository = InMemoryMonsterRepository() }
@AfterEach
fun tearDown() { repository.clear() }
// @BeforeAll / @AfterAll are used for class-wide setup and cleanup.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.
// 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.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.
val response = client.get("/monsters")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("Pyron"))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.
val attackerSpeed = 100
val defenderSpeed = 100
val attackerStarts = attackerSpeed >= defenderSpeed
assertTrue(attackerStarts)
assertEquals(0, maxOf(0, 3 - 99))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.
val response = client.get("/monsters/999")
assertEquals(HttpStatusCode.NotFound, response.status)
// POST /battles with the same IDs should return 400.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.
// Week 02 defines the canonical model with stats.speed.
val first = if (pyron.stats.speed >= aquara.stats.speed) pyron else aquara
println(first.name)PyronMonsterBattles: 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.
// 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] = remainingDebugger 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.
// Inspect in the debugger:
// attackerHp = 100
// defenderHp = 110
// damage = 18
// after update, defenderHp = 92defenderHp = 92MonsterBattles: 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.
// Test failure example:
// Expected: 18
// Actual: 17
// at DamageTest.damageSubtractsDefense(DamageTest.kt:12)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.
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.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.
val response = client.post("/battles") {
contentType(ContentType.Application.Json)
setBody("""{"attackerId":1,"defenderId":2}""")
}
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("winnerId"))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.
./kotlin build
./kotlin run
# Configure host, port and database path for deployment.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.
./kotlin test
./kotlin run
curl http://localhost:8080/monsters
# Windows: kotlin.bat test / kotlin.bat runGET /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.
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/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.
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 / 404Zes 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.
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
}
}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")
}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.
// 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()../kotlin run
./kotlin test
# Windows: kotlin.bat run / kotlin.bat testVerwachte 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.
| Test | Soort | Verwachting |
|---|---|---|
| Normale damage | Unit | 30 − 12 = 18 |
| Minimumdamage | Unit | Minimaal 1 |
| Gelijke Speed | Unit | Eerst geselecteerde begint |
| HP-grens | Unit | HP niet onder 0 |
| Monster aanmaken en ophalen | Integratie | 201 / 200 |
| Monster wijzigen | Integratie | 200 |
| Monster verwijderen | Integratie | 204 |
| Battle starten | Integratie | 200 + winnaar + log |
| Onbekende monster-ID | Integratie | 404 |
| Ongeldige battle-input | Integratie | 400 |
Cheat Sheet
| Begrip | Gebruik |
|---|---|
| Repository | Interface voor dataopslag |
| In-memory / persistent | RAM / opslag die procesherstart overleeft |
| H2 | Relationele database voor lokale ontwikkeling |
| Exposed DSL | Tabellen en SQL-queries met Kotlin |
| Seeddata | Voorspelbare startwaarden |
| Unit / integratie | Geïsoleerde logica / samenwerking tussen onderdelen |
@Test | Een test markeren |
@DisplayName | Leesbare testnaam |
assertEquals | Waarden vergelijken |
assertFailsWith | Verwachte exception testen |
| AAA | Arrange, Act, Assert |
@ParameterizedTest | Dezelfde test met meerdere inputs |
@MethodSource | Parameters uit een methode |
@JvmStatic | Companion-methode als statische JVM-methode aanbieden |
| Lifecycle hooks | Tests voorbereiden en opruimen |
| Fake / mock | Eenvoudige implementatie / interacties verifiëren |
testApplication | Ktor API testen zonder externe server |
| Breakpoints | Uitvoering onderbreken voor inspectie |
./kotlin test | Test-suite uitvoeren |