diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift index f6584e6..e2d0a36 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryDateSelectionViewModelTests.swift @@ -1,29 +1,21 @@ -import XCTest +import Foundation +import Testing @testable import DiaryApp @testable import Chiui -final class DiaryEntryDateSelectionViewModelTests: XCTestCase { - var sut: DiaryEntryDateSelectionViewModel! - var context: DiaryContext! - - override func setUp() async throws { - try await super.setUp() - await MainActor.run { - context = DiaryContext(initialState: DiaryStoreState()) - sut = DiaryEntryDateSelectionViewModel(context) - } - } - - override func tearDown() async throws { - await MainActor.run { - sut = nil - context = nil - } - try await super.tearDown() +@Suite("DiaryEntryDateSelectionViewModel tests") +struct DiaryEntryDateSelectionViewModelTests { + @MainActor + private func makeSUT() -> (sut: DiaryEntryDateSelectionViewModel, context: DiaryContext) { + let context = DiaryContext(initialState: DiaryStoreState()) + let sut = DiaryEntryDateSelectionViewModel(context) + return (sut, context) } + @Test("Store update maps draft date") @MainActor - func testDidStoreUpdateUsesDraftDate() async { + func didStoreUpdateUsesDraftDate() async { + let (sut, context) = makeSUT() let draftDate = Date(timeIntervalSince1970: 1_750_000_000) await context.store.update { state in @@ -34,23 +26,27 @@ final class DiaryEntryDateSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedDate, draftDate) + #expect(sut.state.selectedDate == draftDate) } + @Test("selectedDateChanged persists draft date") @MainActor - func testUpdateSelectedDateUpdatesStoreDraft() async { + func updateSelectedDateUpdatesStoreDraft() async { + let (sut, context) = makeSUT() let selectedDate = Date(timeIntervalSince1970: 1_760_000_000) await sut.sendAwaitingEffects(.selectedDateChanged(selectedDate)) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedDate, selectedDate) + #expect(sut.state.selectedDate == selectedDate) let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftDate, selectedDate) + #expect(storeState.entryDraftDate == selectedDate) } + @Test("confirmSelection dismisses date selection") @MainActor - func testConfirmSelectionDismissesDateSelection() async { + func confirmSelectionDismissesDateSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryDate = true } @@ -59,11 +55,13 @@ final class DiaryEntryDateSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryDate) + #expect(storeState.isSelectingEntryDate == false) } + @Test("cancelSelection dismisses date selection") @MainActor - func testCancelSelectionDismissesDateSelection() async { + func cancelSelectionDismissesDateSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryDate = true } @@ -72,15 +70,16 @@ final class DiaryEntryDateSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryDate) + #expect(storeState.isSelectingEntryDate == false) } + @Test("Reducer emits persistDraftDate effect") @MainActor - func testReducerProducesPersistEffect() { + func reducerProducesPersistEffect() { var state = DiaryEntryDateSelectionViewModel.State() let date = Date(timeIntervalSince1970: 1_760_000_000) let effect = DiaryEntryDateSelectionViewModel.respond(to: .selectedDateChanged(date), state: &state) - XCTAssertEqual(state.selectedDate, date) - XCTAssertEqual(effect, .persistDraftDate(date)) + #expect(state.selectedDate == date) + #expect(effect == .persistDraftDate(date)) } } diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryLocationSelectionViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryLocationSelectionViewModelTests.swift index 931e429..e401972 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryLocationSelectionViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryLocationSelectionViewModelTests.swift @@ -1,29 +1,21 @@ -import XCTest +import Foundation +import Testing @testable import DiaryApp @testable import Chiui -final class DiaryEntryLocationSelectionViewModelTests: XCTestCase { - var sut: DiaryEntryLocationSelectionViewModel! - var context: DiaryContext! - - override func setUp() async throws { - try await super.setUp() - await MainActor.run { - context = DiaryContext(initialState: DiaryStoreState()) - sut = DiaryEntryLocationSelectionViewModel(context) - } - } - - override func tearDown() async throws { - await MainActor.run { - sut = nil - context = nil - } - try await super.tearDown() +@Suite("DiaryEntryLocationSelectionViewModel tests") +struct DiaryEntryLocationSelectionViewModelTests { + @MainActor + private func makeSUT() -> (sut: DiaryEntryLocationSelectionViewModel, context: DiaryContext) { + let context = DiaryContext(initialState: DiaryStoreState()) + let sut = DiaryEntryLocationSelectionViewModel(context) + return (sut, context) } + @Test("Store update maps draft location") @MainActor - func testDidStoreUpdateUsesDraftLocation() async { + func didStoreUpdateUsesDraftLocation() async { + let (sut, context) = makeSUT() await context.store.update { state in state.entrySelectionMode = .addingNew state.entryDraftLocation = "Kyoto" @@ -32,11 +24,13 @@ final class DiaryEntryLocationSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.location, "Kyoto") + #expect(sut.state.location == "Kyoto") } + @Test("Store update maps selected entry location when draft is missing") @MainActor - func testDidStoreUpdateUsesSelectedEntryLocationWhenNoDraft() async { + func didStoreUpdateUsesSelectedEntryLocationWhenNoDraft() async { + let (sut, context) = makeSUT() let entry = DiaryEntry( id: UUID(), title: "Title", @@ -53,21 +47,25 @@ final class DiaryEntryLocationSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.location, "Berlin") + #expect(sut.state.location == "Berlin") } + @Test("locationChanged persists draft location") @MainActor - func testLocationChangedUpdatesStoreDraft() async { + func locationChangedUpdatesStoreDraft() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.locationChanged("Toronto")) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.location, "Toronto") + #expect(sut.state.location == "Toronto") let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftLocation, "Toronto") + #expect(storeState.entryDraftLocation == "Toronto") } + @Test("confirmSelection dismisses location selection") @MainActor - func testConfirmSelectionDismissesLocationSelection() async { + func confirmSelectionDismissesLocationSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryLocation = true } @@ -76,11 +74,13 @@ final class DiaryEntryLocationSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryLocation) + #expect(storeState.isSelectingEntryLocation == false) } + @Test("cancelSelection dismisses location selection") @MainActor - func testCancelSelectionDismissesLocationSelection() async { + func cancelSelectionDismissesLocationSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryLocation = true } @@ -89,14 +89,15 @@ final class DiaryEntryLocationSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryLocation) + #expect(storeState.isSelectingEntryLocation == false) } + @Test("Reducer emits persistDraftLocation effect") @MainActor - func testReducerProducesPersistEffect() { + func reducerProducesPersistEffect() { var state = DiaryEntryLocationSelectionViewModel.State() let effect = DiaryEntryLocationSelectionViewModel.respond(to: .locationChanged("Oslo"), state: &state) - XCTAssertEqual(state.location, "Oslo") - XCTAssertEqual(effect, .persistDraftLocation("Oslo")) + #expect(state.location == "Oslo") + #expect(effect == .persistDraftLocation("Oslo")) } } diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift index a935aba..d97c367 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryMoodSelectionViewModelTests.swift @@ -1,29 +1,21 @@ -import XCTest +import Foundation +import Testing @testable import DiaryApp @testable import Chiui -final class DiaryEntryMoodSelectionViewModelTests: XCTestCase { - var sut: DiaryEntryMoodSelectionViewModel! - var context: DiaryContext! - - override func setUp() async throws { - try await super.setUp() - await MainActor.run { - context = DiaryContext(initialState: DiaryStoreState()) - sut = DiaryEntryMoodSelectionViewModel(context) - } - } - - override func tearDown() async throws { - await MainActor.run { - sut = nil - context = nil - } - try await super.tearDown() +@Suite("DiaryEntryMoodSelectionViewModel tests") +struct DiaryEntryMoodSelectionViewModelTests { + @MainActor + private func makeSUT() -> (sut: DiaryEntryMoodSelectionViewModel, context: DiaryContext) { + let context = DiaryContext(initialState: DiaryStoreState()) + let sut = DiaryEntryMoodSelectionViewModel(context) + return (sut, context) } + @Test("Store update maps draft mood") @MainActor - func testDidStoreUpdateUsesDraftMood() async { + func didStoreUpdateUsesDraftMood() async { + let (sut, context) = makeSUT() await context.store.update { state in state.entrySelectionMode = .addingNew state.entryDraftMood = .great @@ -32,11 +24,13 @@ final class DiaryEntryMoodSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedMood, .great) + #expect(sut.state.selectedMood == .great) } + @Test("Store update maps selected entry mood when draft is missing") @MainActor - func testDidStoreUpdateUsesSelectedEntryMoodWhenNoDraft() async { + func didStoreUpdateUsesSelectedEntryMoodWhenNoDraft() async { + let (sut, context) = makeSUT() let entry = DiaryEntry( id: UUID(), title: "Title", @@ -52,21 +46,25 @@ final class DiaryEntryMoodSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedMood, .bad) + #expect(sut.state.selectedMood == .bad) } + @Test("selectedMoodChanged persists draft mood") @MainActor - func testUpdateSelectedMoodUpdatesStoreDraft() async { + func updateSelectedMoodUpdatesStoreDraft() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.selectedMoodChanged(.amazing)) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedMood, .amazing) + #expect(sut.state.selectedMood == .amazing) let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftMood, .amazing) + #expect(storeState.entryDraftMood == .amazing) } + @Test("confirmSelection dismisses mood selection") @MainActor - func testConfirmSelectionDismissesMoodSelection() async { + func confirmSelectionDismissesMoodSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryMood = true } @@ -75,11 +73,13 @@ final class DiaryEntryMoodSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryMood) + #expect(storeState.isSelectingEntryMood == false) } + @Test("cancelSelection dismisses mood selection") @MainActor - func testCancelSelectionDismissesMoodSelection() async { + func cancelSelectionDismissesMoodSelection() async { + let (sut, context) = makeSUT() await context.store.update { state in state.isSelectingEntryMood = true } @@ -88,14 +88,15 @@ final class DiaryEntryMoodSelectionViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(0.1)) let storeState = await context.store.state - XCTAssertFalse(storeState.isSelectingEntryMood) + #expect(storeState.isSelectingEntryMood == false) } + @Test("Reducer emits persistDraftMood effect") @MainActor - func testReducerProducesPersistEffect() { + func reducerProducesPersistEffect() { var state = DiaryEntryMoodSelectionViewModel.State() let effect = DiaryEntryMoodSelectionViewModel.respond(to: .selectedMoodChanged(.great), state: &state) - XCTAssertEqual(state.selectedMood, .great) - XCTAssertEqual(effect, .persistDraftMood(.great)) + #expect(state.selectedMood == .great) + #expect(effect == .persistDraftMood(.great)) } } diff --git a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift index 730b53b..bab60cb 100644 --- a/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift +++ b/Examples/DiaryApp/DiaryAppTests/DiaryEntryViewModelTests.swift @@ -1,115 +1,121 @@ -import XCTest +import Foundation +import Testing @testable import DiaryApp @testable import Chiui -final class DiaryEntryViewModelTests: XCTestCase { - var sut: DiaryEntryViewModel! - var context: DiaryContext! - - override func setUp() async throws { - try await super.setUp() - await MainActor.run { - context = DiaryContext(initialState: DiaryStoreState()) - sut = DiaryEntryViewModel(context) - } - } - - override func tearDown() async throws { - await MainActor.run { - sut = nil - context = nil - } - try await super.tearDown() +@Suite("DiaryEntryViewModel tests") +struct DiaryEntryViewModelTests { + @MainActor + private func makeSUT() -> (sut: DiaryEntryViewModel, context: DiaryContext) { + let context = DiaryContext(initialState: DiaryStoreState()) + let sut = DiaryEntryViewModel(context) + return (sut, context) } - // MARK: - State Tests - + @Test("Initial state") @MainActor - func testInitialState() async { - XCTAssertEqual(sut.state.title, "") - XCTAssertEqual(sut.state.content, "") - XCTAssertEqual(sut.state.savingStatus, .no) - XCTAssertFalse(sut.state.isEditing) - XCTAssertFalse(sut.state.isDateSelectionPresented) - XCTAssertFalse(sut.state.isMoodSelectionPresented) - XCTAssertFalse(sut.state.isLocationSelectionPresented) - XCTAssertEqual(sut.state.selectedMood, .okay) - XCTAssertEqual(sut.state.selectedLocation, "") - XCTAssertEqual(sut.state.entryTitle, "") + func initialState() async { + let (sut, _) = makeSUT() + #expect(sut.state.title == "") + #expect(sut.state.content == "") + #expect(sut.state.savingStatus == .no) + #expect(sut.state.isEditing == false) + #expect(sut.state.isDateSelectionPresented == false) + #expect(sut.state.isMoodSelectionPresented == false) + #expect(sut.state.isLocationSelectionPresented == false) + #expect(sut.state.selectedMood == .okay) + #expect(sut.state.selectedLocation == "") + #expect(sut.state.entryTitle == "") } + @Test("isSavingDisabled reflects title and save flow") @MainActor - func testIsSavingDisabled() async { + func isSavingDisabled() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.titleChanged("")) - XCTAssertTrue(sut.state.isSavingDisabled) + #expect(sut.state.isSavingDisabled) await sut.sendAwaitingEffects(.titleChanged("Test Title")) - XCTAssertFalse(sut.state.isSavingDisabled) + #expect(sut.state.isSavingDisabled == false) await sut.sendAwaitingEffects(.finishRequested(save: true)) - XCTAssertFalse(sut.state.isSavingDisabled) + #expect(sut.state.isSavingDisabled == false) } + @Test("titleChanged updates title") @MainActor - func testUpdateTitle() async { + func updateTitle() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.startEditing) await sut.sendAwaitingEffects(.titleChanged("Test Title")) - XCTAssertEqual(sut.state.title, "Test Title") - XCTAssertTrue(sut.state.isEditing) + #expect(sut.state.title == "Test Title") + #expect(sut.state.isEditing) } + @Test("contentChanged updates content") @MainActor - func testUpdateContent() async { + func updateContent() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.contentChanged("Test Content")) - XCTAssertEqual(sut.state.content, "Test Content") + #expect(sut.state.content == "Test Content") } + @Test("startEditing enables editing mode") @MainActor - func testStartEditing() async { + func startEditing() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.startEditing) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertTrue(sut.state.isEditing) + #expect(sut.state.isEditing) } + @Test("finishRequested without save exits editing mode") @MainActor - func testFinishEditingWithoutSave() async { + func finishEditingWithoutSave() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.startEditing) await sut.sendAwaitingEffects(.titleChanged("Test Title")) await sut.sendAwaitingEffects(.contentChanged("Test Content")) - XCTAssertTrue(sut.state.isEditing) + #expect(sut.state.isEditing) await sut.sendAwaitingEffects(.finishRequested(save: false)) - XCTAssertFalse(sut.state.isEditing) + #expect(sut.state.isEditing == false) } + @Test("finishRequested with save marks state as saved") @MainActor - func testFinishEditingWithSave() async { + func finishEditingWithSave() async { + let (sut, _) = makeSUT() await sut.sendAwaitingEffects(.titleChanged("Test Title")) await sut.sendAwaitingEffects(.contentChanged("Test Content")) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertFalse(sut.state.isEditing) + #expect(sut.state.isEditing == false) await sut.sendAwaitingEffects(.startEditing) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertTrue(sut.state.isEditing) + #expect(sut.state.isEditing) await sut.sendAwaitingEffects(.finishRequested(save: true)) - XCTAssertEqual(sut.state.savingStatus, .saved) - XCTAssertFalse(sut.state.isEditing) + #expect(sut.state.savingStatus == .saved) + #expect(sut.state.isEditing == false) } + @Test("Saving empty title does not append entries") @MainActor - func testFinishEditingWithEmptyTitle() async { + func finishEditingWithEmptyTitle() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.titleChanged("")) await sut.sendAwaitingEffects(.finishRequested(save: true)) - XCTAssertEqual(sut.state.savingStatus, .saved) - XCTAssertFalse(sut.state.isEditing) + #expect(sut.state.savingStatus == .saved) + #expect(sut.state.isEditing == false) let state = await context.store.state - XCTAssertEqual(state.entries.count, 0) + #expect(state.entries.count == 0) } + @Test("Saving new entry writes full payload to store") @MainActor - func testStoreUpdateOnNewEntry() async { + func storeUpdateOnNewEntry() async { + let (sut, context) = makeSUT() let selectedDate = Date(timeIntervalSince1970: 1_700_000_000) await context.store.update { state in state.entrySelectionMode = .addingNew @@ -125,17 +131,19 @@ final class DiaryEntryViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(2.1)) let state = await context.store.state - XCTAssertEqual(state.entries.count, 1) + #expect(state.entries.count == 1) let entry = state.entries[0] - XCTAssertEqual(entry.title, "Test Title") - XCTAssertEqual(entry.content, "Test Content") - XCTAssertEqual(entry.createdAt, selectedDate) - XCTAssertEqual(entry.mood, .great) - XCTAssertEqual(entry.location, "Lisbon") + #expect(entry.title == "Test Title") + #expect(entry.content == "Test Content") + #expect(entry.createdAt == selectedDate) + #expect(entry.mood == .great) + #expect(entry.location == "Lisbon") } + @Test("Saving edited entry updates existing row") @MainActor - func testStoreUpdateOnEditEntry() async { + func storeUpdateOnEditEntry() async { + let (sut, context) = makeSUT() let initialEntry = DiaryEntry( id: UUID(), title: "Initial Title", @@ -162,22 +170,24 @@ final class DiaryEntryViewModelTests: XCTestCase { try? await Task.sleep(for: .seconds(2.1)) let state = await context.store.state - XCTAssertEqual(state.entries.count, 1) + #expect(state.entries.count == 1) let entry = state.entries[0] - XCTAssertEqual(entry.title, "Updated Title") - XCTAssertEqual(entry.content, "Updated Content") - XCTAssertEqual(entry.createdAt, updatedDate) - XCTAssertEqual(entry.mood, .amazing) - XCTAssertEqual(entry.location, "Prague") + #expect(entry.title == "Updated Title") + #expect(entry.content == "Updated Content") + #expect(entry.createdAt == updatedDate) + #expect(entry.mood == .amazing) + #expect(entry.location == "Prague") } + @Test("Store mapping updates local state from selection mode") @MainActor - func testStoreStateMapping() async { + func storeStateMapping() async { + let (sut, context) = makeSUT() await context.store.update { state in state.entrySelectionMode = .addingNew } try? await Task.sleep(for: .seconds(0.2)) - XCTAssertEqual(sut.state.entryTitle, "New Entry") + #expect(sut.state.entryTitle == "New Entry") let entry = DiaryEntry( id: UUID(), @@ -191,91 +201,104 @@ final class DiaryEntryViewModelTests: XCTestCase { state.entrySelectionMode = .selecting(entry) } try? await Task.sleep(for: .seconds(0.2)) - XCTAssertEqual(sut.state.entryTitle, "Test Title") - XCTAssertEqual(sut.state.title, "Test Title") - XCTAssertEqual(sut.state.content, "Test Content") - XCTAssertEqual(sut.state.selectedDate, entry.createdAt) - XCTAssertEqual(sut.state.selectedMood, entry.mood) - XCTAssertEqual(sut.state.selectedLocation, entry.location) + #expect(sut.state.entryTitle == "Test Title") + #expect(sut.state.title == "Test Title") + #expect(sut.state.content == "Test Content") + #expect(sut.state.selectedDate == entry.createdAt) + #expect(sut.state.selectedMood == entry.mood) + #expect(sut.state.selectedLocation == entry.location) await context.store.update { state in state.entrySelectionMode = .no } try? await Task.sleep(for: .seconds(0.2)) - XCTAssertEqual(sut.state.entryTitle, "Test Title") - XCTAssertEqual(sut.state.title, "Test Title") - XCTAssertEqual(sut.state.content, "Test Content") + #expect(sut.state.entryTitle == "Test Title") + #expect(sut.state.title == "Test Title") + #expect(sut.state.content == "Test Content") } + @Test("openMoodSelection seeds draft and presents modal") @MainActor - func testOpenMoodSelectionSeedsDraftAndPresentsModal() async { + func openMoodSelectionSeedsDraftAndPresentsModal() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.selectedMoodChanged(.great)) try? await Task.sleep(for: .seconds(0.1)) await sut.sendAwaitingEffects(.openMoodSelection) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertTrue(sut.state.isMoodSelectionPresented) + #expect(sut.state.isMoodSelectionPresented) let storeState = await context.store.state - XCTAssertTrue(storeState.isSelectingEntryMood) - XCTAssertEqual(storeState.entryDraftMood, .great) + #expect(storeState.isSelectingEntryMood) + #expect(storeState.entryDraftMood == .great) } + @Test("selectedMoodChanged persists draft mood") @MainActor - func testUpdateSelectedMoodPersistsDraftMood() async { + func updateSelectedMoodPersistsDraftMood() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.selectedMoodChanged(.amazing)) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedMood, .amazing) + #expect(sut.state.selectedMood == .amazing) let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftMood, .amazing) + #expect(storeState.entryDraftMood == .amazing) } + @Test("selectedDateChanged persists draft date") @MainActor - func testUpdateSelectedDatePersistsDraftDate() async { + func updateSelectedDatePersistsDraftDate() async { + let (sut, context) = makeSUT() let date = Date(timeIntervalSince1970: 1_850_000_000) await sut.sendAwaitingEffects(.selectedDateChanged(date)) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedDate, date) + #expect(sut.state.selectedDate == date) let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftDate, date) + #expect(storeState.entryDraftDate == date) } + @Test("openLocationSelection seeds draft and presents modal") @MainActor - func testOpenLocationSelectionSeedsDraftAndPresentsModal() async { + func openLocationSelectionSeedsDraftAndPresentsModal() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.selectedLocationChanged("Vienna")) try? await Task.sleep(for: .seconds(0.1)) await sut.sendAwaitingEffects(.openLocationSelection) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertTrue(sut.state.isLocationSelectionPresented) + #expect(sut.state.isLocationSelectionPresented) let storeState = await context.store.state - XCTAssertTrue(storeState.isSelectingEntryLocation) - XCTAssertEqual(storeState.entryDraftLocation, "Vienna") + #expect(storeState.isSelectingEntryLocation) + #expect(storeState.entryDraftLocation == "Vienna") } + @Test("selectedLocationChanged persists draft location") @MainActor - func testUpdateSelectedLocationPersistsDraftLocation() async { + func updateSelectedLocationPersistsDraftLocation() async { + let (sut, context) = makeSUT() await sut.sendAwaitingEffects(.selectedLocationChanged("Seoul")) try? await Task.sleep(for: .seconds(0.1)) - XCTAssertEqual(sut.state.selectedLocation, "Seoul") + #expect(sut.state.selectedLocation == "Seoul") let storeState = await context.store.state - XCTAssertEqual(storeState.entryDraftLocation, "Seoul") + #expect(storeState.entryDraftLocation == "Seoul") } + @Test("Reducer emits performSave effect for finishRequested(save: true)") @MainActor - func testReducerReturnsSaveEffect() async { + func reducerReturnsSaveEffect() async { var state = DiaryEntryViewModel.State() state.title = "Title" let effect = DiaryEntryViewModel.respond(to: .finishRequested(save: true), state: &state) - XCTAssertEqual(state.savingStatus, .saving) - guard case .performSave = effect else { - XCTFail("Expected performSave effect") - return - } + #expect(state.savingStatus == .saving) + #expect({ + if case .performSave? = effect { + return true + } + return false + }()) } } diff --git a/README.md b/README.md index 250ea73..380da7c 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,41 @@ struct EntryView: ContextualView { } ``` +### 4. Unit tests + +```swift +import Testing +@testable import App + +@Suite("EntryViewModel tests") +struct EntryViewModelTests { + @Test("Reducer: titleChanged updates state and emits effect") + func reducer_titleChanged_updatesState_andEmitsEffect() { + var state = EntryState() + let effect = EntryViewModel.respond(to: .titleChanged("Hello"), state: &state) + + #expect(state.title == "Hello") + #expect(effect == .persistTitle("Hello")) + } + + @Test("send(saveTapped) runs effect and updates store") + @MainActor + func send_saveTapped_runsEffectAndUpdatesStore() async { + let context = EntryContext(initialState: .init(), api: .mockSuccess) + let sut = EntryViewModel(context) + + _ = sut.send(.titleChanged("New entry")) + if let task = sut.send(.saveTapped) { + await task.value + } + + let store = await context.store.state + #expect(store.entries.last?.title == "New entry") + #expect(sut.state.isSaving == false) + } +} +``` + ## Rules 1. **`respond` is pure.** It's a `class func` with no `self`. It cannot touch the store, coordinator, or any service.