Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .agents/skills/testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: testing
description: How to write tests in Foliary. Make sure to use this skill whenever the user asks to add, update, fix, refactor, or review unit tests, DAO tests, repository tests, ViewModel tests, or test fixtures. Use it even when the request only mentions a production file if the work clearly requires test coverage.
---
# Writing Tests in Foliary

Follow the existing `commonTest` conventions already used in Foliary. Keep tests small, deterministic, and aligned with the production abstraction being exercised.

## General rules

### Fixtures First

Never manually build domain or entity objects inside tests when the object can be represented by a fixture.

1. Reuse an existing `fixture` helper first.
2. If no fixture exists yet, create one in `commonTest` near the tested type. Before creating or updating fixture functions you MUST read [`references/fixture.md`](./references/fixture.md).

### General testing rules

1. Put tests in the `commonTest` source set unless there is a clear reason to use another source set.
2. Use Kotest assertions such as `shouldBe`. Do not use plain `assert()`.
3. Use `runTest` for suspend and flow-based tests.
4. Name test methods with backticks and sentence-style names.
5. Before writing or updating this kind of test, read `templates/base-unit-test.md`.
6. Mock collaborators instead of standing up integration infrastructure unless the test is explicitly integration-oriented.
7. Use Mokkery with patterns like `mock<T>()`, `every { ... } returns ...`, and `verifySuspend { ... }` when verification is needed.
8. Mokkery cannot mock final classes, and Kotlin classes are final by default, so regular classes that need to be mocked in tests must be annotated with `@Open`.

## DAO Tests

1. DAO tests must extend the shared `DaoTest` base class.
2. Use the real in-memory database and obtain the DAO from `database`.
3. Before writing or updating this kind of test, read `templates/dao-unit-test.md`.

## ViewModel Tests

1. ViewModel tests must extend the shared `ViewModelTest` base class.
2. Implement `createViewModel()` and build the subject there.
3. Use the inherited `test { ... }` helper rather than calling Orbit test APIs directly.
4. Assert states with `expectState(...)` and side effects with `expectSideEffect(...)`.
5. Use `advanceTimeBy(...)` or `advanceUntilIdle()` when the ViewModel relies on delays or queued coroutines.
6. Before writing or updating this kind of test, read `templates/view-model-unit-test.md`.

## Implementation Checklist

1. Check whether a shared base class already exists for this test type.
2. Check whether a fixture already exists for every object you need.
3. Create or extend fixtures before manually constructing objects.
4. Keep assertions focused on the behavior under test.
70 changes: 70 additions & 0 deletions .agents/skills/testing/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"skill_name": "testing",
"evals": [
{
"id": 1,
"prompt": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` in `commonTest`. Reuse existing task fixtures, mock `TaskDao` and `TimeProvider` with Mokkery, and assert the returned `Flow` with `.first()`.",
"expected_output": "A repository-style unit test that stays in commonTest, uses Mokkery mocks, uses `Task.fixture()`, stubs the DAO with `flowOf(...)`, and asserts with Kotest `shouldBe`.",
"files": [],
"expectations": [
"The solution keeps the test in `commonTest`.",
"The solution uses Mokkery mocks for `TaskDao` and `TimeProvider`.",
"The solution reuses `Task.fixture()` instead of manually building task objects.",
"The solution stubs the DAO flow with `flowOf(...)` and reads it with `.first()`.",
"The solution uses Kotest assertions such as `shouldBe`."
]
},
{
"id": 2,
"prompt": "Update `TaskDaoTest` coverage for `findTodayTasks()` so it verifies overdue tasks and tasks due today. Use the real in-memory database rather than mocks.",
"expected_output": "A DAO test that extends `DaoTest`, gets the DAO from `database`, inserts fixture entities, and reads the result flow with `.first()`.",
"files": [],
"expectations": [
"The solution extends `DaoTest`.",
"The solution gets the DAO from `database` instead of mocking it.",
"The solution inserts fixture entities rather than manually constructing them.",
"The solution reads DAO flow results with `.first()`.",
"The solution keeps the assertion focused on the query behavior."
]
},
{
"id": 3,
"prompt": "Create a `SignInViewModel` test for the loading state and the magic-link request flow. Keep it aligned with our Orbit test helpers.",
"expected_output": "A ViewModel test that extends `ViewModelTest`, builds the subject in `createViewModel()`, uses the inherited `test { ... }` helper, asserts states with `expectState(...)`, and verifies the repository call with Mokkery.",
"files": [],
"expectations": [
"The solution extends `ViewModelTest`.",
"The solution creates the subject in `createViewModel()`.",
"The solution uses the inherited `test { ... }` helper.",
"The solution asserts states with `expectState(...)`.",
"The solution verifies the collaborator call with Mokkery when needed."
]
},
{
"id": 4,
"prompt": "Create a fixture function from scratch for this new entity and follow Foliary's fixture conventions exactly:\n\n```kotlin\npackage dev.appoutlet.foliary.feature.garden.model\n\nimport dev.appoutlet.foliary.data.task.database.entity.Location\nimport kotlin.uuid.Uuid\n\ndata class Garden(\n val id: Uuid,\n val name: String,\n val description: String?,\n val location: Location?,\n val tags: List<String>,\n) {\n companion object\n}\n```\n\nReturn the `GardenFixture.kt` content you would add under `commonTest`.",
"expected_output": "A `GardenFixture.kt` implementation under the same package in `commonTest` that defines `fun Garden.Companion.fixture(...)`, mirrors the constructor signature exactly, keeps defaults non-null and realistic, uses `Location.fixture()` for the nested entity, and provides at least one default tag.",
"files": [],
"expectations": [
"The solution places the fixture in the same package as `Garden`, but under `commonTest`.",
"The solution defines `fun Garden.Companion.fixture(...)`.",
"The solution mirrors the constructor signature exactly, including argument order, names, types, and nullability.",
"The solution uses non-null, realistic defaults for every parameter, including `description`.",
"The solution uses `Location.fixture()` as the default value for `location`.",
"The solution gives `tags` a default list with at least one element."
]
},
{
"id": 5,
"prompt": "Fix this failing test setup. `ReminderRepositoryImpl` depends on a regular Kotlin class `ReminderTimeProvider` that Mokkery cannot mock. Show the minimal production-code change needed to make mocking work and explain why.\n\n```kotlin\npackage dev.appoutlet.foliary.data.reminder\n\nclass ReminderTimeProvider {\n fun now(): Instant = Clock.System.now()\n}\n\nclass ReminderRepositoryImpl(private val timeProvider: ReminderTimeProvider) {\n fun currentTime(): Instant = timeProvider.now()\n}\n\nclass ReminderRepositoryImplTest {\n private val timeProvider = mock<ReminderTimeProvider>()\n private val subject = ReminderRepositoryImpl(timeProvider)\n}\n```",
"expected_output": "A short answer that explains Kotlin classes are final by default, Mokkery cannot mock final classes, and the fix is to annotate `ReminderTimeProvider` with `@Open` (or make it an `interface` / `open class`).",
"files": [],
"expectations": [
"The solution explains that regular Kotlin classes are final by default.",
"The solution states that Mokkery cannot mock final classes.",
"The solution mentions applying the `@Open` annotation to `ReminderTimeProvider`.",
"The solution mentions an acceptable alternative such as making `ReminderTimeProvider` an interface or an `open class`."
]
}
]
}
34 changes: 34 additions & 0 deletions .agents/skills/testing/evals/trigger-evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
[
{
"query": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` and reuse the existing task fixtures instead of hand-building objects.",
"should_trigger": true
},
{
"query": "Fix `TaskDaoTest` after I changed the Room query for today's tasks. Keep it using the real database and update the assertions.",
"should_trigger": true
},
{
"query": "Write a `SignInViewModel` test for the magic-link flow using our Orbit test helpers and whatever shared base class the repo already has.",
"should_trigger": true
},
{
"query": "There is no fixture for this new entity yet. Create the companion `fixture()` function from scratch in `commonTest` before you add the repository test.",
"should_trigger": true
},
{
"query": "Create a new task details screen in Compose Multiplatform and wire it into navigation.",
"should_trigger": false
},
{
"query": "Update `AGENTS.md` so it tells agents to load the testing skill before writing tests.",
"should_trigger": false
},
{
"query": "Run `./gradlew foliary:jvmTest --tests \"dev.appoutlet.foliary.data.task.TaskRepositoryImplTest\"` and tell me whether it passes.",
"should_trigger": false
},
{
"query": "Refactor `TaskRepositoryImpl` production logic to use a different query, but do not add or change any tests yet.",
"should_trigger": false
}
]
34 changes: 34 additions & 0 deletions .agents/skills/testing/references/fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Fixture Creation In Foliary

Read this file before creating or updating any fixture function.

## Workflow

1. Read the entity primary constructor in `commonMain`.
2. Create the fixture file in `commonTest` under the exact same package as the entity.
3. Name the file `[EntityName]Fixture.kt`.
4. Implement the fixture as a companion extension function: `fun Entity.Companion.fixture(...)`.
5. Copy the fixture function arguments from the entity primary constructor exactly.
6. Keep the same argument names, types, nullability, and order as the entity constructor.
7. Return the entity by passing those arguments through directly.
8. If you need a concrete shape reference, read the relevant example in `templates/` before writing the fixture.

## Strict Rules

1. The fixture file must live in the same package as the entity, but under the `commonTest` source set.
2. The fixture function arguments must match the entity primary constructor exactly.
3. The fixture default values must be sensible and represent a realistic object.
4. Default values must never be `null`, even when the parameter type is nullable.
5. When a parameter is another entity, use that entity's fixture as the default value.
6. When a parameter type is a collection, the default value must contain at least one element.
7. Do not invent fixture-only parameters. The fixture signature must mirror the entity constructor.
8. Do not reorder parameters in the fixture.

## Default Value Guidance

1. For IDs, use a generated but valid ID such as `Uuid.random()`.
2. For names, titles, descriptions, and URLs, use readable real-looking values.
3. For dates, choose values that make sense together as one coherent object.
4. For nullable timestamps, enums, nested objects, and strings, still provide a non-null default.
5. For nested entities, call that nested entity's `fixture()`.
6. For collections, create at least one realistic child element using fixtures where appropriate.
27 changes: 27 additions & 0 deletions .agents/skills/testing/templates/base-unit-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Base Unit Test Pattern

Use this shape for repository tests and other unit tests that mock collaborators.

```kotlin
private val mockTaskDao = mock<TaskDao>()
private val mockTimeProvider = mock<TimeProvider>()
private val subject = TaskRepositoryImpl(mockTaskDao, mockTimeProvider)

@Test
fun `should return tasks due today and overdue tasks`() = runTest {
val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z")
val fixtureTasks = listOf(Task.fixture())

every { mockTimeProvider.endOfToday() } returns endOfToday
every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks)

subject.findTodayTasks().first() shouldBe fixtureTasks
}
```

## Notes

1. Use Mokkery for collaborators.
2. Prefer fixtures for returned objects.
3. For `Flow`-based APIs, stub with `flowOf(...)` and assert with `.first()`.
4. Keep the test focused on the unit boundary instead of persistence details.
30 changes: 30 additions & 0 deletions .agents/skills/testing/templates/dao-unit-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# DAO Unit Test Pattern

Use this shape for DAO tests against the real in-memory Room database.

```kotlin
class TaskDaoTest : DaoTest() {
private val dao by lazy { database.taskDao() }

@Test
fun `should return overdue tasks and tasks due today`() = runTest {
val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z")
val overdueTask = Task.fixture(
dueDate = Instant.parse("2026-07-20T22:00:00Z"),
completionDate = null,
)

dao.save(overdueTask)

val result = dao.findTodayTasks(endOfToday).first()
result.map { it.id } shouldBe listOf(overdueTask.id)
}
}
```

## Notes

1. Extend `DaoTest`.
2. Use fixtures for inserted entities.
3. Read flow results with `.first()`.
4. When full entity equality is noisy because of persistence normalization, compare stable fields such as IDs.
45 changes: 45 additions & 0 deletions .agents/skills/testing/templates/view-model-unit-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ViewModel Unit Test Pattern

Use this shape for Orbit-based ViewModel tests.

```kotlin
class SignInViewModelTest : ViewModelTest<SignInViewModel, SignInViewData, SignInAction>() {
private val sessionStatusFlow = MutableStateFlow<SessionStatus>(SessionStatus.Initializing)
private val mockAuthenticationRepository = mock<AuthenticationRepository>(MockMode.autoUnit)
.apply { every { sessionStatus() } returns sessionStatusFlow }

override fun createViewModel() = SignInViewModel(
authenticationRepository = mockAuthenticationRepository,
deeplinkDispatcher = DeepLinkDispatcher,
)

@Test
fun `should show loading when session is initializing`() = test {
expectState(SignInViewData.Loading)
}

@Test
fun `should request magic link`() {
val email = "user@example.com"
sessionStatusFlow.value = SessionStatus.NotAuthenticated()

test {
expectState(SignInViewData.NotAuthenticated())

viewModel.onEvent(SignInEvent.OnSendMagicLink(email))

expectState(SignInViewData.NotAuthenticated(requestingMagicLink = true))
verifySuspend { mockAuthenticationRepository.requestMagicLink(email) }
expectState(SignInViewData.MagicLinkSent(email))
}
}
}
```

## Notes

1. Extend `ViewModelTest`.
2. Build the subject in `createViewModel()`.
3. Use the inherited `test { ... }` helper.
4. Assert states and side effects through the provided Orbit helpers.
5. Set initial collaborator state before entering the `test { ... }` block when startup behavior depends on it.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ Pods/
*yarn.lock
.opencode/
i18n.cache
.agents/skills/*-workspace
17 changes: 3 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Additionally, the project contains email templates for authentication (e.g., sig
| Goal | Command |
|------|---------|
| Run single test class | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest"` |
| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return todays tasks"` |
| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return tasks due today and overdue tasks"` |

### CI & Hooks
- **Pre-push hook**: `config/githooks/pre-push.sh` runs `./gradlew detekt`
Expand All @@ -52,7 +52,7 @@ Additionally, the project contains email templates for authentication (e.g., sig
| Functions/Properties | `camelCase` | `onEvent`, `userName` |
| Constants | `PascalCase` | `DefaultTimeout` |
| Test classes | `PascalCase` + `Test` | `TaskRepositoryImplTest` |
| Test methods | Backticks | `` `should return todays tasks` `` |
| Test methods | Backticks | `` `should return tasks due today and overdue tasks` `` |
### Types & Immutability
- Prefer `val` over `var`; use `data class` with `val` properties
- Use `sealed interface` for state/actions
Expand All @@ -68,15 +68,4 @@ Additionally, the project contains email templates for authentication (e.g., sig

**Important**: Always use Kotest assertions (e.g., `result shouldBe expected`) instead of standard `assert()`.

```kotlin
class FeatureTest {
@Test
fun `should do something`() = runTest {
val result = subject.doSomething()
result shouldBe expectedValue
}
}
```

### UI Tests
Use `runComposeUiTest` and `onNodeWithTag` (via `modifier.testTag("TagName")`).
**Skill**: Before writing, updating, or reviewing tests, load the `testing` skill.
Loading