This document covers the automated unit test plan, the scripts that implement it, and a summary of changes that followed from testing for each test.
Manual API smoke sequences under backend/http/ support exploratory and integration checks. They are noted briefly but are not the unit-test evidence set for this requirement.
Repository: d424-software-engineering-capstone on GitLab
Service-layer behavior is the primary unit-test focus. Controllers stay thin; repositories and peer services are mocked so each test exercises business rules in isolation (authorization, caching, armor-class math, equipment rules, spell selection validation).
| Suite | File | Style | Test methods |
|---|---|---|---|
| Application smoke | backend/src/test/java/com/kdnth/campaignkeep/CampaignkeepApplicationTests.java |
@SpringBootTest context load |
1 |
| Notes | .../note/NoteServiceImplTest.java |
JUnit 5 + Mockito | 9 |
| Session logs | .../sessionlog/SessionLogServiceImplTest.java |
JUnit 5 + Mockito | 9 |
| Spells | .../spell/SpellServiceImplTest.java |
JUnit 5 + Mockito | 6 |
| Inventory | .../item/InventoryServiceImplTest.java |
JUnit 5 + Mockito | 6 |
Total: 31 automated tests in the Maven Surefire suite.
- JUnit 5 (Jupiter) for test lifecycle and assertions
- Mockito (
@ExtendWith(MockitoExtension.class),@Mock,@InjectMocks) for doubles - Spring Boot Test only for
CampaignkeepApplicationTests(loads the application context) - Maven Surefire (
mvn test) to run the suite from thebackend/module
CampaignKeep puts authorization and domain rules in services (requireMaster, ownership checks, inventory constraints). Unit-testing those methods catches rule regressions without standing up PostgreSQL or HTTP for every case. That matches the product’s architecture and keeps feedback fast during development.
From the repository root:
cd backend
mvn testIn IntelliJ IDEA: open a test class (or the backend module), right-click -> Run ‘Tests in ...’ / Run ‘All Tests’.
Note: Mockito’s inline mock maker needs a JVM that allows agent attachment.
contextLoads (A-1) needs env vars. That test is @SpringBootTest and boots Flyway/JPA against a real database. It runs only when DB_URL matches jdbc:.*. Put these on the JUnit run configuration (IntelliJ often does not reuse the main application’s env):
DB_URL(example:jdbc:postgresql://localhost:5432/campaignkeep)DB_USERNAMEDB_PASSWORD(may be empty)JWT_SECRET(≥ 32 characters)JWT_EXP(milliseconds, e.g.86400000)
If DB_URL is missing, A-1 is skipped and the 30 Mockito service tests still run. A failure saying 'url' must start with "jdbc" means the test run saw an empty/invalid URL. Fix the run-config env. Ignore CONDITIONS EVALUATION REPORT and scroll to that root cause.
Each plan row is one executable @Test method. Columns describe intent, inputs/stubs, expected outcome, and where evidence belongs.
| ID | Test method | Objective | Expected result |
|---|---|---|---|
| A-1 | contextLoads |
Application Spring context starts with production wiring (live DB + DB_URL / JWT_*) |
Passes when env is set; skipped when DB_URL is unset |
- Added environment variables to the
CampaignkeepApplicationTests.contextLoadsrun configuration in IntelliJ.
Scope: player note read/upsert; master note list/create/delete; membership and ownership guards.
| ID | Test method | Objective | Expected result |
|---|---|---|---|
| N-1 | getPlayerNote_returnsEmptyShellWhenNoNoteExists |
Missing player note returns empty shell | id null, body empty string |
| N-2 | upsertPlayerNote_createsNoteOnFirstSave |
First save creates note + character link | Note id set; body preserved; junction saved |
| N-3 | upsertPlayerNote_updatesExistingNote |
Second save updates body without new junction | Body updated; no extra junction save |
| N-4 | getPlayerNote_rejectsWhenCharacterNotAttachedToCampaign |
PC must be in campaign | NoSuchElementException |
| N-5 | getPlayerNote_rejectsNonOwner |
Only owning player may read | AccessDeniedException |
| N-6 | listMasterNotes_requiresMaster |
Players cannot list master notes | AccessDeniedException |
| N-7 | createMasterNote_persistsNoteAndLink |
Master create writes note + campaign link | Id/title set; link saved to campaign |
| N-8 | deleteMasterNote_removesJunctionAndNote |
Master delete removes link and note | Both deletes invoked |
| N-9 | listMasterNotes_returnsSummariesWithoutBody |
List returns summary fields | One summary with id/title (list API is summary-shaped) |
- None were needed. All tests passed
Scope: list/get for members; create/delete for master; campaign scoping (404 when wrong campaign).
| ID | Test method | Objective | Expected result |
|---|---|---|---|
| S-1 | listSessionLogs_allowsMember |
Member can list logs | One summary with expected id/title |
| S-2 | listSessionLogs_rejectsNonMember |
Non-member blocked before repository access | AccessDeniedException; no repo list call |
| S-3 | getSessionLog_allowsMember |
Member can read detail | Id, title/body match fixture |
| S-4 | getSessionLog_returns404WhenWrongCampaign |
Log must belong to campaign | NoSuchElementException |
| S-5 | createSessionLog_requiresMaster |
Player cannot create | AccessDeniedException; no save |
| S-6 | createSessionLog_persistsLog |
Master create persists title, body, campaign | Response fields match; save captures campaign |
| S-7 | deleteSessionLog_requiresMaster |
Player cannot delete | AccessDeniedException; no delete |
| S-8 | deleteSessionLog_removesLog |
Master delete removes entity | delete called on found log |
| S-9 | deleteSessionLog_returns404WhenWrongCampaign |
Delete scopes by campaign | NoSuchElementException; no delete |
- None were needed. All tests passed
Scope: local cache hit; dnd5eapi miss -> cache; spellbook ownership; creation-time selection rules.
| ID | Test method | Objective | Expected result |
|---|---|---|---|
| SP-1 | getSpellById_returnsCachedSpell |
Cached spell returns detail | Name and school match |
| SP-2 | getSpellByApiIndex_fetchesAndCachesOnMiss |
Miss fetches API then saves | Name set; save invoked |
| SP-3 | addSpellToCharacter_persistsLinkForOwner |
Owner can add spell | Link saved; name returned |
| SP-4 | addSpellToCharacter_deniesNonOwner |
Non-owner cannot add | AccessDeniedException; no save |
| SP-5 | validateCreationSpells_rejectsSpellsForFighter |
Fighter cannot take creation spells | IllegalArgumentException |
| SP-6 | validateCreationSpells_requiresWizardCantripAndLevelOneCounts |
Wizard needs correct cantrip + level-1 counts | Incomplete list fails; full valid set passes |
- None were needed. All tests passed
Scope: AC recalculation, Strength gate on armor, starting equipment flag, gold ownership, grant weight capacity.
| ID | Test method | Objective | Expected result |
|---|---|---|---|
| I-1 | recalculateArmorClass_unarmored_usesTenPlusDex |
Unarmored AC = 10 + DEX mod | AC = 12 for DEX 14 |
| I-2 | recalculateArmorClass_lightArmorAndShield |
Leather (11 + DEX) + shield (+2) | AC = 15 |
| I-3 | equipItem_blocksArmorWhenStrengthTooLow |
Chain mail STR 13 gate | IllegalArgumentException when STR 10 |
| I-4 | submitStartingEquipment_setsFlagAndGrantsItems |
Starting kit grants item and sets flag | Flag true; item saved; character saved |
| I-5 | updateGold_requiresOwner |
Non-owner cannot change gold | AccessDeniedException |
| I-6 | grantItem_blocksWhenOverWeightCapacity |
Master grant blocked if over capacity | ConflictException; no inventory save |
- None were needed. All tests passed
| Area | Cases planned | Pass criterion |
|---|---|---|
| Application context | 1 | Context loads |
| Notes | 9 | All methods green |
| Session logs | 9 | All methods green |
| Spells | 6 | All methods green |
| Inventory | 6 | All methods green |
| Total | 31 | mvn test BUILD SUCCESS |
- None were needed. All tests passed
Scripts live under backend/src/test/java/com/kdnth/campaignkeep/. Run them with mvn test or the IDE.
| Script path | Class under test |
|---|---|
CampaignkeepApplicationTests.java |
Spring Boot application bootstrap |
note/NoteServiceImplTest.java |
NoteServiceImpl |
sessionlog/SessionLogServiceImplTest.java |
SessionLogServiceImpl |
spell/SpellServiceImplTest.java |
SpellServiceImpl |
item/InventoryServiceImplTest.java |
InventoryServiceImpl |
Uses @Mock repositories and CampaignService, @InjectMocks NoteServiceImpl. Example ownership rejection:
@Test
void getPlayerNote_rejectsNonOwner() {
when(campaignService.requireMembership(CAMPAIGN_ID, USER_ID)).thenReturn(masterMembership);
User otherPlayer = new User();
otherPlayer.setId(999L);
PlayableCharacter otherCharacter = new PlayableCharacter();
otherCharacter.setId(CHARACTER_ID);
otherCharacter.setPlayer(otherPlayer);
when(playableCharacterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(otherCharacter));
assertThrows(
AccessDeniedException.class,
() -> noteService.getPlayerNote(CAMPAIGN_ID, CHARACTER_ID, USER_ID)
);
} @Test
void createSessionLog_requiresMaster() {
when(campaignService.requireMaster(CAMPAIGN_ID, USER_ID))
.thenThrow(new AccessDeniedException("denied"));
assertThrows(
AccessDeniedException.class,
() -> sessionLogService.createSessionLog(
CAMPAIGN_ID,
USER_ID,
new CreateSessionLogRequest("Session 1", "Notes")
)
);
verify(sessionLogRepository, never()).save(any(SessionLog.class));
} @Test
void getSpellByApiIndex_fetchesAndCachesOnMiss() {
when(spellRepository.findByApiIndex("fire-bolt")).thenReturn(Optional.empty());
when(dnd5eApiClient.fetchSpell("fire-bolt")).thenReturn(apiResponse("fire-bolt", "Fire Bolt", 0));
when(spellRepository.save(any(Spell.class))).thenAnswer(invocation -> {
Spell saved = invocation.getArgument(0);
saved.setId(SPELL_ID);
return saved;
});
SpellDetailResponse response = spellService.getSpellByApiIndex("fire-bolt");
assertEquals("Fire Bolt", response.name());
verify(spellRepository).save(any(Spell.class));
} @Test
void recalculateArmorClass_unarmored_usesTenPlusDex() {
when(characterItemRepository.findByCharacter_Id(CHARACTER_ID)).thenReturn(List.of());
inventoryService.recalculateArmorClass(character);
assertEquals(12, character.getArmorClass().intValue());
}
@Test
void equipItem_blocksArmorWhenStrengthTooLow() {
Armor chainMail = heavyArmor();
CharacterItem item = inventoryItem(1L, chainMail);
character.setStrength((short) 10);
when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character));
when(characterItemRepository.findById(1L)).thenReturn(Optional.of(item));
assertThrows(
IllegalArgumentException.class,
() -> inventoryService.updateInventoryItem(
CHARACTER_ID,
1L,
new UpdateInventoryItemRequest(EquipmentSlot.armor, false, null),
USER_ID
)
);
}package com.kdnth.campaignkeep;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class CampaignkeepApplicationTests {
@Test
void contextLoads() {
}
}When the environment is configured correctly, Surefire should resemble:
Tests run: 31, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
If CampaignkeepApplicationTests fails with datasource errors, set DB_URL, DB_USERNAME, DB_PASSWORD, JWT_SECRET, and JWT_EXP the same way as for normal app startup, then re-run. Service suites (N/S/SP/I) do not need a live database because repositories are mocked.
| Document | Purpose |
|---|---|
| User guide | End-user workflows exercised during exploratory testing |
| Setup and maintenance guide | Environment required to run mvn test and the app |
| Design document | OOP structure under test (services around Character/Item) |
| README | Project entry point and doc index |
| Item | Location |
|---|---|
| Unit test scripts | backend/src/test/java/com/kdnth/campaignkeep/** |
| This testing write-up | docs/software-testing.md |
| Screenshots | Insert in sections 2 and 4 where marked |













