EP 353 · Tour of SQLiteData · Feb 2, 2026 ·Members

Video #353: Tour of SQLiteData: Advanced Testing

smart_display

Loading stream…

Video #353: Tour of SQLiteData: Advanced Testing

Episode: Video #353 Date: Feb 2, 2026 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep353-tour-of-sqlitedata-advanced-testing

Episode thumbnail

Description

We clean up our test suite and make use of the expectDifference helper, for precisely describing changes to state in an exhaustive fashion. We will then rapidly add test coverage using the forthcoming “Point-Free Way” skills documents. Finally, we will achieve the seemingly impossible by writing a test against iCloud sharing!

Video

Cloudflare Stream video ID: 401ff0b465987a3740bb1c66cd836b60 Local file: video_353_tour-of-sqlitedata-advanced-testing.mp4 *(download with --video 353)*

References

Transcript

0:05

Ok, we have now exercise more of our feature’s logic and behavior in a simple unit test. We can emulate the user tapping on various buttons and typing into text fields to add a new player to their game, and then we assert that indeed the game was added to the model’s state. We can now be very confident that this feature works when run in the simulator or on device, as long as the view is reading the state from the model to construct its hierarchy and as long as the view is calling the model’s methods when the user takes an action. Stephen

0:37

Things are looking good, but there are some improvements we can make to this. We had to copy-and-paste quite a bit of set up code in that test just to get to a point where we could actually start emulating user behavior and making assertions. And the assertion itself was quite verbose. We are going to make both of those aspects much better, and we are going to take a look at how we can use AI to write a few more tests for us.

1:00

Let’s take a look. Test improvements

1:02

Right now both of our tests start the same way: let game = try await #require( database.read { db in try Game.find(UUID(0)).fetchOne(db) } ) model = GameModel(game: game) await model.task()

1:13

And if we had dozens of tests, we would need these lines of code written dozens of times.

1:17

This kind of set up code can be moved into a central place so that every test inside the suite has it run before the actual test function starts running. And we do that by simply adding an initializer to the suite type: init() { }

1:33

Whatever work is performed in here will be performed before each test function.

1:38

We could just literally copy-and-paste that code into the init , but we would need to make the init async and throwing: init() async throws { let game = try await #require( database.read { db in try Game.find(UUID(0)).fetchOne(db) } ) let model = GameModel(game: game) await model.task() }

1:52

But the model created in here needs to be made available to all of the tests. So, to do that we add a stored property to the test suite: struct GameFeatureTests { let model: GameModel … }

2:02

…and assign it in the init: model = GameModel(game: game)

2:04

Now one quirk of property wrappers in initializers is that you cannot access their wrapped value directly before all other properties are initialized. So we can’t use the database in a bare fashion like this: database.read { db in … }

2:16

The workaround is to go through its underscored property, and then access the wrapped value: _database.wrappedValue.read { db in … }

2:22

It’s strange, but it gets the job done. With that done we can now delete all of those lines from the test functions, and the suite still passes.

2:32

Next, let’s take a look at this very verbose assertion: model.addPlayerButtonTapped() model.newPlayerName = "Blob Esq" model.saveNewPlayerButtonTapped() try await model.$rows.load() expectNoDifference( model.rows, [ GameModel.Row( player: Player( id: UUID(4), gameID: UUID(0), name: "Blob Sr", score: 3 ) ), GameModel.Row( player: Player( id: UUID(5), gameID: UUID(0), name: "Blob Jr", score: 2 ) ), GameModel.Row( player: Player( id: UUID(3), gameID: UUID(0), name: "Blob", score: 1 ) ), GameModel.Row( player: Player( id: UUID(6), gameID: UUID(0), name: "Blob Esq", score: 0 ) ), ] )

2:39

On the one hand it’s nice that we can exhaustively prove what data is held in the rows property, but on the other hand it’s a real pain to write this code and difficult to see what exact change is even being asserted on.

2:49

Well, our Custom Dump library has a wonderful tool that brings a new level of testing to these kinds of assertions, and it was heavily inspired by the testing tools we developed in the Composable Architecture.

2:58

There is a function in Custom Dump called expectDifference : expectDifference( <#Equatable#>, <#message: String?#>, operation: <#() throws -> Void#>, changes: <#(inout Equatable) throws -> Void#> )

3:04

The first argument is an equatable piece of state you want to assert against, so in this case its the rows in the model: expectDifference( model.rows, <#message: String?#>, operation: <#() throws -> Void#>, changes: <#(inout Equatable) throws -> Void#> )

3:12

The second argument is an optional failure message, which we can omit. expectDifference( model.rows, operation: <#() throws -> Void#>, changes: <#(inout Equatable) throws -> Void#> )

3:15

The next argument is a trailing closure where you will perform actions that cause the state you are asserting on to change. In this case we can put our full user script right inside: expectDifference(model.rows) { model.addPlayerButtonTapped() model.newPlayerName = "Blob Esq" model.saveNewPlayerButtonTapped() try await model.$rows.load() } changes: { <#inout Equatable#> in <#code#> }

3:29

And then the last argument is another trailing closure that is handed a mutable copy of the state we are asserting against, in this case the rows: expectDifference(model.rows) { model.addPlayerButtonTapped() model.newPlayerName = "Blob Esq" model.saveNewPlayerButtonTapped() try await model.$rows.load() } changes: { rows in <#code#> }

3:37

The state represents the value of the rows before the operation is executed, and so it is an out-of-date value. Our job is to mutate it to bring it up-to-date. In particular, we expect a single row to be appended to the end of the rows: @Test func addPlayer() async throws { await expectDifference(model.rows) { model.addPlayerButtonTapped() model.newPlayerName = "Blob Esq" model.saveNewPlayerButtonTapped() try await model.$rows.load() } changes: { rows in rows.append( GameModel.Row( player: Player( id: UUID(6), gameID: UUID(0), name: "Blob Esq", score: 0 ) ) ) } } And that’s it. There’s no need to assert on all of the other state in the rows because we don’t expect any changes in that state.

4:12

But, if in the future we added extra logic to our feature that slightly changed things, we would instantly be notified because this assertion would fail. As a very silly example, suppose that we wanted it so that at most 3 players can be in any game, and if you ever add a 4th player we delete the oldest player from the game.

4:30

SQL is powerful enough to do such a thing in a quite compact syntax. We aren’t going to build this query from scratch, but here it is: try Player .where { $0.id.in( Player .select(\.id) .where { $0.gameID.eq(game.id) } .order { $0.rowid } .limit { _ in #sql("max(0, \(Player.where { $0.gameID.eq(game.id) }.count() - 3))") } ) } .delete() .execute(db)

4:52

With that change if we run tests we get a failure letting us know that the first row was removed: addPlayer(): Issue recorded: Difference: … [ − [0]: GameModel.Row( − player: Player( − id: UUID(00000000-0000-0000-0000-000000000004), − gameID: UUID(00000000-0000-0000-0000-000000000000), − name: "Blob Sr", − score: 3 − ), − imageData: nil − ), … (3 unchanged), ] (Expected: −, Actual: +)

5:04

This is absolutely amazing. This is giving us exhaustive test coverage on the rows property so that we can be sure we know exactly how our feature’s logic and behavior are executing.

5:27

There’s quite a bit more functionality in this model I want to test, but rather than writing those tests from scratch, let’s see if AI can help us. In previous episodes we used Xcode’s AI assistant to help us write some code, and it leveraged a collection of skill documents we are currently working on, called “The Point-Free Way”, to add functionality to the app in a style that is most in line with how we do things and how our libraries are intended to be used.

5:49

Xcode’s assistant works for the most part, but we found it a little cumbersome in practice. If you are using AI tools you are more likely to be using something like Claude or Codex, and so that is what we are going to do now. I am going to open a Codex session in the directory of this project right now: $ codex

6:18

And I am going to ask it to write a new test that shows what happens when a player’s score is incremented in the GameModel … Prompt Write a test that shows what happens when a player’s score is incremented in the GameModel.

6:32

After a bit of time we get the full explanation of what it did: Codex I’m focusing on skill areas related to Testing , planning to open relevant files and search their contents to understand testing approaches in the code. Explored List ls I’m preparing to examine the test files to assess the existing skill-related tests and gather information for testing strategy. Explored Read SKILL.md Found that Dependencies and CustomDump skills are needed next, marking them as open tasks. Explored Read SKILL.md Search rg --files Search GameModel|Game in GameFeature.swift Read GameFeature.swift , GameFeatureTests.swift I’m preparing a test in GameFeatureTests using expectDifference to verify that incrementing the top player’s score updates only that player’s score in the rows, reloading rows after increment to capture changes while preserving row order. Confirming test setup for value types Verified that expectDifference applies to value-type rows and Dependencies/CustomDump are already imported; ready to add the test function using apply_patch . Edited Scorekeeper/ScorekeeperTests/GameFeatureTests.swift (+11 -0) } + + @Test func incrementScore() async throws { + let player = model.rows[0].player + + await expectDifference(model.rows) { + model.incrementButtonTapped(for: player) + try await model.$rows.load() + } changes: { rows in + rows[0].player.score = 4 + } + } } I will finalize the response by explaining the skills applied, noting that tests have not been run yet, and suggesting running tests. I’ll also include a numbered list of recommended next steps for clarity. Using Testing + CustomDump + Dependencies skills to add a mutation-focused test. Added an incrementScore test that calls model.incrementButtonTapped , reloads rows, and asserts the minimal score change on the top-sorted player using expectDifference . This keeps the assertion value-oriented and avoids reassigning entire rows in Scorekeeper/ScorekeeperTests/GameFeatureTests.swift .

6:32

Without even looking at any of the explanation and just look at the test it wrote, it totally nailed it: @Test func incrementScore() async throws { let player = model.rows[0].player await expectDifference(model.rows) { model.incrementButtonTapped(for: player) try await model.$rows.load() } changes: { rows in rows[0].player.score = 4 } }

6:38

It chooses an existing player in the rows, then invokes the incrementButtonTapped method on the model for that player, it reloads the data in the rows, and then asserts that the player’s score in that row incremented to 4. And if we run the test it passes.

6:48

The main reason it was able to write such a succinct test is because it uncovered the skill docs for CustomDump: Codex Found that Dependencies and CustomDump skills are needed next, marking them as open tasks.

7:00

That skill document meticulously details the proper way to use the expectDifference tool, such as making sure to apply a minimal mutation to the rows in order to bring the value to its current state, and to not use logic in the trailing closure.

7:12

For example, the test would have passed like this too: rows[0].player.score += 1

7:14

But that is not as strong of an assertion. That merely asserts that the score went up by one, but does not assert that we know what the exact value of the score is at this time.

7:22

This is fun! Let’s try another. Let’s next prompt code to write a test that increments Blob Jr’s score twice: Prompt write a test that increments Blob Jr’s score twice

7:36

Because Blob Jr has a score of 2 that should bring them to 4, which would mean they have the highest score, and that should sort them to the top: Codex I’m checking existing tests for skill matching and planning to open the skill file for editing to add or improve test coverage. Explored Read SKILL.md I need to open the Dependencies and CustomDump skills to gather more information for the task. Explored Read SKILL.md , GameFeatureTests.swift I’m creating a test in GameFeatureTests that increments Blob Jr’s score twice and verifies the score changes from 2 to 4 using expectDifference with model.rows reloaded after increments to ensure database consistency. Edited Scorekeeper/ScorekeeperTests/GameFeatureTests.swift (+12 -0) + @Test func incrementBlobJrTwice() async throws { + let player = model.rows[1].player + + await expectDifference(model.rows) { + model.incrementButtonTapped(for: player) + model.incrementButtonTapped(for: player) + try await model.$rows.load() + } changes: { rows in + rows[1].player.score = 4 + } + } + @Test func sharing() async throws { Added a focused test that increments Blob Jr twice and asserts the score change via expectDifference in Scorekeeper/ScorekeeperTests/GameFeatureTests.swift .

7:49

Well, unfortunately it does not seem to have been able to pick up on the fact that the results should have been sorted. If we apply this code and run the test, we of course get a failure: incrementBlobJrTwice(): Issue recorded: Difference: … [ − [0]: GameModel.Row( − player: Player( − id: UUID(00000000-0000-0000-0000-000000000004), − gameID: UUID(00000000-0000-0000-0000-000000000000), − name: "Blob Sr", − score: 3 − ), − imageData: nil − ), [0]: GameModel.Row(…), + [1]: GameModel.Row( + player: Player( + id: UUID(00000000-0000-0000-0000-000000000004), + gameID: UUID(00000000-0000-0000-0000-000000000000), + name: "Blob Sr", + score: 3 + ), + imageData: nil + ), [2]: GameModel.Row(…) ] (Expected: −, Actual: +)

8:11

But this is OK. We find it better that when prompting codex to write tests for us we should also be describing the expectations. That way we can prove that we personally know how the system should be working at a high level.

8:21

And so let’s delete that test, and try again: Prompt Write another test to increment Blob Jr’s score twice, and show that that causes them to be sorted to the top

8:45

Now it does a lot better: Codex I’m outlining a test that increments Blob Jr’s score twice before reloading, then checks the reordered rows so Blob Jr leads with score 4 followed by Blob Sr and Blob. I’ll apply minimal mutations with expectDifference by updating score and swapping array elements to reflect the order change without replacing the entire array. Finalizing test mutations and reload I’m confirming the test will increment Blob Jr’s score twice, then reload and apply minimal mutations by updating the score and swapping the rows to reflect correct descending order, ensuring the model’s rows start sorted descending before the test. Edited Scorekeeper/ScorekeeperTests/GameFeatureTests.swift (+13 -0) } + + @Test func incrementBlobJrTwiceSortsToTop() async throws { + let player = model.rows[1].player + + await expectDifference(model.rows) { + model.incrementButtonTapped(for: player) + model.incrementButtonTapped(for: player) + try await model.$rows.load() + } changes: { rows in + rows[1].player.score = 4 + rows.swapAt(0, 1) + } + } } Added a test that increments Blob Jr twice and asserts the reorder to the top using a minimal diff in Scorekeeper/ScorekeeperTests/GameFeatureTests.swift . And now it nailed it. It sets Blob Jr’s score, and then swap’s its position with Blob Sr, who was in first. If we apply this code and run tests we will see it passes.

8:58

It’s also interesting to look into codex’s explanation of things because it picks up a number of concepts we describe in our skill documents, such as this: Codex I’ll apply minimal mutations with expectDifference by updating score and swapping array elements to reflect the order change without replacing the entire array.

9:22

Ok, let’s do one more. Let’s ask codex to create a test to exercise the delete functionality: Prompt Write a test that deletes Blob Sr and asserts on how the rows change

0:00

And again it nailed it: Codex I’m adding a test that uses expectDifference on rows after deleting the player at offset 0; since deletePlayers writes to the database but doesn’t reload rows, I’ll explicitly reload rows in the test to ensure correctness. Edited Scorekeeper/ScorekeeperTests/GameFeatureTests.swift (+9 -0) } + + @Test func deleteBlobSr() async throws { + await expectDifference(model.rows) { + model.deletePlayers(at: IndexSet(integer: 0)) + try await model.$rows.load() + } changes: { rows in + rows.remove(at: 0) + } + } } Added a delete test that removes Blob Sr and asserts the minimal row change in Scorekeeper/ScorekeeperTests/GameFeatureTests.swift. If we apply this code and run tests it passes. Testing iCloud sharing

10:01

Ok, we have now greatly improved the conciseness of our tests by using the expectDifference tool that allows us to exhaustively assert on a piece of state, but we have also rapidly written 3 more tests using Codex. And it’s amazing to see that Codex was able to take a number of hints from our skill documents to write the tests in a style exactly as we would have done by hand. And that’s because those skill documents are meticulously crafted by us, by hand, to precisely describe the correct way to use our libraries and apply our patterns. Brandon

10:28

We’ve got one more topic we want to discuss, and that is writing tests that cover what happens when the user shares a game. When a user shares their game, that flips some state in the SyncMetadata table managed by the SyncEngine , and we are able to actually join to that table to query for its data. You might think that we don’t have a chance in hell at testing any of that logic because it involves CloudKit APIs and interacting with iCloud servers and even involves other iCloud users.

10:57

Well, luckily for us, we built the SyncEngine with all of this in mind, and just as we could interact with all of that functionality in previews, we can also write tests against it.

11:07

Let’s take a look.

11:09

First of all we don’t have anything to actually test with regards to sharing in the GameModel because we aren’t actually displaying anything to the user related to sharing. Back in the root view we are performing a complex query to query the SyncMetadata for isShared : Game .group(by: \.id) .leftJoin(Player.all) { $0.id.eq($1.gameID) } .order { $1.count().desc() } .leftJoin(SyncMetadata.all) { $0.syncMetadataID.eq($2.id) } .select { Row.Columns( game: $0, isShared: $2.isShared.ifnull(false), playerCount: $1.count() ) }

11:37

But, as we mentioned before, all of this logic is buried in the view and so completely hidden from us in unit tests.

11:45

So, to explore writing tests for iCloud sharing flows, we will add a tiny new feature to our game detail view. If the game is currently being actively shared we will display a row at the tip of the list to let the user know it is shared, and we will provide a button to un-share the game.

12:19

So, we need to start with some state in the model that represents if the game is currently being shared: @ObservationIgnored @FetchOne var isShared = false

12:36

And when reloading the data for the model we can load a query into this @FetchOne that finds the sync metadata associated with the game and grabs its isShared state: try await $isShared.load( SyncMetadata.find(game.syncMetadataID).select(\.isShared) )

13:24

And then in the view we will check if the game is shared, and if so display a label with an un-share button: if model.isShared { HStack { Text("Shared with others") Spacer() Button("Stop sharing", role: .destructive) { Task { await model.stopSharingButtonTapped() } } } }

14:03

And tapping that button will invoke a method on the model that simply un-shares the record by invoking the unshare(record:) method: func stopSharingButtonTapped() async { await withErrorReporting { try await syncEngine.unshare(record: game) } }

14:27

That is literally all it takes to implement this feature. We can run the app in the simulator to see that it does indeed work.

14:57

But how do we write a test for this? Well, for the most part it can be written like any other test we have in this suite, but with one small extra step to take. But let’s start as if we don’t know about that extra step and see what goes wrong.

15:10

I’ll add a new function for the test: @Test func sharing() async throws { }

15:15

I would hope that testing this functionality could be as simple as emulating the user tapping on the “Share” button: await model.shareButtonTapped()

15:30

Then reloading the freshest data in the isShared state and seeing that the boolean flips to true: try await model.$isShared.load() #expect(model.isShared)

15:49

And further, I would hope I could emulate the user tapping the un-share button and then assert that the isShared state flipped to false: await model.stopSharingButtonTapped() try await model.$isShared.load() #expect(!model.isShared)

16:03

Well, this unfortunately fails. The isShared state does not flip to true: sharing(): Expectation failed: (model → Scorekeeper.GameModel).isShared → false

16:22

But there’s another error in the logs: sharing(): Caught error: SharingError(recordTableName: nil, recordPrimaryKey: nil, reason: SQLiteData.SyncEngine.(unknown context at $1058864a8).SharingError.Reason.syncEngineNotRunning, debugDescription: "Sync engine is not running. Invoke the 'start()' method, or use the 'startImmediately' argument when initializing the engine.")

16:30

Tests do not automatically start the sync engine unless the sync engine is explicitly configured to do so. We avoid this overhead to keep tests lean and fast while making it easy to opt into sync engine behavior when it matters. So let’s start the sync engine in this test: @Test( .dependencies { try await $0.defaultSyncEngine.start() } ) func sharing() { … }

17:15

But there’s another error in the logs: sharing(): Caught error: SharingError(recordTableName: Optional("games"), recordPrimaryKey: Optional("00000000-0000-0000-0000-000000000000"), reason: SQLiteData.SyncEngine.(unknown context at $1058864a8).SharingError.Reason.recordMetadataNotFound, debugDescription: "No sync metadata found for record. Has the record been saved to the database and synchronized with iCloud? Invoke 'SyncEngine.sendChanges()' to force synchronization.")

17:29

This is an error thrown by the share(record:) method when it tries to create a share for a record that has not yet been synchronized to iCloud. The record has been created locally in our database, but that change has not yet been set off to the cloud database, which in the case of tests would mean the mock database that we showed off previously.

18:00

So, when the test first starts we need to make sure to take all of the data that was seeded in our database and synchronize it to the mock cloud database: @Test func sharing() async throws { try await syncEngine.syncChanges() … }

18:42

Now when we run tests we get further along without failures, but we still do get a failure that the isShared state did not flip back to false after un-sharing: sharing(): Expectation failed: !((model → Scorekeeper.GameModel).isShared → true → true)

18:57

Well, this is because after tapping the “Stop sharing” button we need to again synchronize those changes to the mock cloud database: await model.stopSharingButtonTapped() try await syncEngine.syncChanges()

19:16

And now the test passes. And this means we can have a great amount of confidence that when the user taps the “Share” button, it eventually results in the isShared state flipping to true and our UI updating. And then if they tap “Stop sharing”, a moment later the isShared state will flip back to false and the UI will update accordingly.

19:39

And this leads us to an important lesson in writing tests that interact with sync metadata. We have an extra responsibility to explicitly sync changes to the cloud database because the mock sync engine used under the hood does not automatically do that for us. Conclusion

20:00

This now concludes our mini-addition to the SQLiteData tour. We still can’t believe we did a 4-part series to tour the library without once talking about Xcode previews or unit testing. But now we’ve done that, and done it right. Stephen

20:14

The “too long, didn’t watch” of it is that the SQLiteData library was built from the ground up with previews and testing in mind, and nothing about the library gets in your way to exploring every little nuanced corner of your application’s logic. This even includes things that typically you might think were impossible to test, such as iCloud sharing, but lo and behold, it’s possible. Brandon

20:36

That’s it for now, but we do have some really exciting episode coming very soon that pick up a new topic, and of course we will be releasing the “Point-Free Way” skill documents very soon too.

20:46

Until next time! References SQLiteData Brandon Williams & Stephen Celis A fast, lightweight replacement for SwiftData, powered by SQL. https://github.com/pointfreeco/sqlite-data StructuredQueries A library for building SQL in a safe, expressive, and composable manner. https://github.com/pointfreeco/swift-structured-queries Downloads Sample code 0353-sqlite-data-tour-pt6 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .