TM-SGNL-iOS/SignalServiceKit/tests/Storage/Database/SDSDatabaseStorage/V2/ToyExample.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

479 lines
16 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import XCTest
@testable import SignalServiceKit
// In this file I present a toy example of how to use
// V2 DB components, and the code design they encourage.
// (Hereafter referred simply as DB, with "old" database
// types referred to as "SDS" types)
// As of time of writing, DB _does not_ help you test
// things like SQL statements or GRDB functions that
// compose SQL statements for you.
// Instead, its purpose is to encapsulate "storage"
// in general for classes that consume data from
// disk but whose primary business is transforming
// and doing things with that data.
// This is best demonstrated by example.
private class FooBarManager {}
// Here is a class, FooBarManager.
// It does things with Foos and Bars, like
// check their properties and perform network requests.
private struct Foo: Codable {
let text: String
}
private struct Bar: Codable {
let num: Int
}
extension FooBarManager {
func doTheFooBar(foo: Foo, bar: Bar) {
let newFoo: Foo
if bar.num == 42 {
newFoo = Foo(text: "The meaning of life")
} else {
newFoo = foo
}
issueNetworkRequest(foo: newFoo)
}
func issueNetworkRequest(foo: Foo) {
// TODO
}
}
// FooBarManager is primarily concerned with what you _do_
// with Foos and Bars, not how they are stored.
// But if it _did_ want to persist them, it would
// delegate that responsibility to another class.
private class FooFinder {
init() {}
func getFoo(transaction: SDSAnyReadTransaction) -> Foo? {
let sql = "SELECT text FROM model_Foo LIMIT 1"
guard let text = try? String.fetchOne(transaction.unwrapGrdbRead.database, sql: sql) else {
return nil
}
return Foo(text: text)
}
}
private class BarFinder {
// This is an static method vs instance method for getFoo above.
// The reason why will be obvious later on. For now, ignore it.
static func getBar(transaction: SDSAnyReadTransaction) -> Bar? {
let sql = "SELECT text FROM model_Foo LIMIT 1"
guard let num = try? Int.fetchOne(transaction.unwrapGrdbRead.database, sql: sql) else {
return nil
}
return Bar(num: num)
}
}
// DB _does not_ help you test these Finder classes. Instead,
// it helps you mock out the Finder classes so you can test
// FooBarManager.
// Traditionally, FooBarManager might consume foos and bars from storage like so:
extension FooBarManager {
// Takes a transaction to ensure foo and bar are read
// from the same database snapshot and aren't out of sync.
func doTheFooBar(transaction: SDSAnyReadTransaction) {
let foo = FooFinder().getFoo(transaction: transaction)!
let bar = BarFinder.getBar(transaction: transaction)!
doTheFooBar(foo: foo, bar: bar)
}
}
// That works in production code, but when you try and test you run into some problems:
private class FooBarManagerTest: SSKBaseTest {
var fooBarManager: FooBarManager!
func testGetFooAndBar() throws {
throw XCTSkip("Don't actually want to run this test")
#if false
// This comes from SSKEnvironment, which by its very creation
// requires setting up countless managers and running all sorts
// of database startup tasks.
let databaseStorage = self.databaseStorage
// Write a bunch of SQL queries to insert Foo(s) and Bar(s)
// into the database.
databaseStorage.read { transaction in
fooBarManager.doTheFooBar(transaction: transaction)
}
// Assert stuff...
#endif
}
}
// You HAD to create the entire SSKEnvironment just to run your measly test.
// That's a lot of overhead for your simple unit test, and it also makes
// things hard to reason about.
// Most importantly, FooBarManager doesn't actually _care_ about any of that. It doesn't
// even care that there is a database; thats FooFinder and BarFinder's problem.
// All it knows is it has this "transaction" object, which is a total black box, that
// it blindly passes along to its dependencies to do the real work.
// It is aware that passing a single transaction around (vs one per operation)
// guarantees transactionality, but that is all.
// NOTE: one option would be to refactor SDSDatabaseStorage to to be more flexible.
// That involves way more refactoring than was required to create DB and other V2 classes,
// so we did that instead.
// Using DB instead, we do this:
extension FooFinder {
func getFoo(transaction: DBReadTransaction) -> Foo? {
let sdsTx = SDSDB.shimOnlyBridge(transaction)
return getFoo(transaction: sdsTx)
}
}
extension BarFinder {
static func getBar(transaction: DBReadTransaction) -> Bar? {
let sdsTx = SDSDB.shimOnlyBridge(transaction)
return getBar(transaction: sdsTx)
}
}
extension FooBarManager {
func doTheFooBar(transaction: DBReadTransaction) {
let foo = FooFinder().getFoo(transaction: transaction)!
let bar = BarFinder.getBar(transaction: transaction)!
doTheFooBar(foo: foo, bar: bar)
}
}
// The code you actually write is pretty much identical, except for the types.
// You just have to do one conversion, to unwrap the DBReadTransaction into
// an SDSAnyRead transaction you can actually use.
// Here's the test:
extension FooBarManagerTest {
func testGetFooAndBar_2() throws {
throw XCTSkip("Don't actually want to run this test")
#if false
// This is a dumb, simple class with no dependencies.
// It has no overhead, no startup jobs, nothing.
// All it does is give you things that _look_
// like transactions, but crash if you try and
// actually use them to read from a database.
let db = InMemoryDB()
// Here goes state we set up for our test, e.g. creating
// the Foo(s) and Bar(s) we expect to "read" from the "db".
// We will get to how to do that later, hold tight.
// No SQL queries, though.
db.read { transaction in
fooBarManager.doTheFooBar(transaction: transaction)
}
// Assert stuff...
#endif
}
}
// Our unit test is now _truly_ isolated. There are no other dependencies
// or implicit startup jobs running around. What happens is what the test
// says happens. No more flakes because an unrelated component is flaky and
// crashes on startup every once in a while.
// The only problem is that this test will crash and fail as written.
// That's because it uses FooFinder and BarFinder, and we made it so they
// would accept a DBReadTransaction, but as soon as we give them the fake
// transactions MockDB generates, they will crash when they call SDSDB.shimOnlyBridge.
// This is a good thing; the test _should_ fail because we have made a mistake:
// we have not mocked out FooFinder, BarFinder, or any other dependencies FooBarManager has.
// We _must_ define our dependencies in our test, and tell them how they should behave,
// so that we can isolate FooBarManager and test its logic independently of its dependencies.
// So how do we mock out FooFinder and BarFinder?
// FooFinder is easier; its a simple class and uses instance methods, so we can
// easily wrap it in a protocol with an alternate mock implementation.
private protocol FooFinderProtocol {
func getFoo(transaction: DBReadTransaction) -> Foo?
}
extension FooFinder: FooFinderProtocol {}
private class FooFinderMock: FooFinderProtocol {
init() {}
var fooToReturn: Foo?
func getFoo(transaction: DBReadTransaction) -> Foo? {
// Ignore the transaction! This mock is only used
// in tests which pass around fake transactions that
// crash if you try and unwrap them.
return fooToReturn
}
}
// I took a shortcut earlier and had FooBarManager instantiate a FooFinder inline
// where it was needed. We'd now instead update the initializer to take a FooFinderProtocol:
private class FooBarManager2 {
let fooFinder: FooFinderProtocol
init(fooFinder: FooFinderProtocol) {
self.fooFinder = fooFinder
}
func doTheFooBar(transaction: DBReadTransaction) {
_ = fooFinder.getFoo(transaction: transaction)!
_ = BarFinder.getBar(transaction: transaction)!
// doTheFooBar(foo: foo, bar: bar)
}
}
// We'd create a "real" instance like so:
func ex() {
_ = FooBarManager2(fooFinder: FooFinder())
}
// And in tests we pass a FooFinderMock:
class FooBarManagerTest2: XCTestCase {
private var fooFinderMock: FooFinderMock!
private var fooBarManager: FooBarManager2!
override func setUp() {
fooFinderMock = FooFinderMock()
fooBarManager = FooBarManager2(fooFinder: fooFinderMock)
}
func testGetFooAndBar_2() throws {
throw XCTSkip("Don't actually want to run this test")
#if false
let db = InMemoryDB()
// Setting and returning this value doesn't go through
// the db, doesn't rely on the schema being set up, etc.
// FooBarManager wants a Foo and doesn't care how it was stored;
// this test now better matches that without getting into
// implementation details like SQL.
fooFinderMock.fooToReturn = Foo(text: "Hello, world!")
db.read { transaction in
fooBarManager.doTheFooBar(transaction: transaction)
}
// Assert stuff...
#endif
}
}
// We are one step closer now, but this test will still fail.
// The FooBarManager instance in our test will still call the static method on
// BarFinder, which crashes if its given a fake MockDB-generated transaction.
// BarFinder is a little bit tricker than FooFinder. It uses class methods, not instance
// methods, so we can't create a mock instance with different behavior.
// Since this is just an example, you can imagine that BarFinder has other
// complications:
//
// * It might be enormous with tons of methods you don't want to stub out 1 by 1
// for your little basic unit test.
// * It might get called from objc classes, or be written in objc itself,
// and you don't want to migrate a bunch of objc code for you basic test.
// * It might be badly designed, doing ten thousand things which should
// be broken up into smaller, more directed classes.
//
// In any case, while refactoring BarFinder would be ideal, the worst
// outcome is giving up due to the overhead and not writing a test for
// FooBarManager at all _because_ of BarFinder's ugliness.
// There is a shortcut, which is just "shimming" out the few things
// your specific class needs from BarFinder, like so:
private protocol FooBarManager_BarFinderShim {
// BarFinder might have TONS of other methods on it that
// FooBarManager never invokes, so we can exclude them
// from the FooBarManager-scoped shim. No other class should
// use this shim, so it only needs what FooBarManager uses.
func getBar(transaction: DBReadTransaction) -> Bar?
}
private class FooBarManager_BarFinderWrapper: FooBarManager_BarFinderShim {
func getBar(transaction: DBReadTransaction) -> Bar? {
// In our toy example, we already modified BarFinder to
// take a DBReadTransaction instead of an SDSAnyReadTransaction.
// In the real world, BarFinder would be an existing class that doesn't
// have that, and the Wrapper is the place to do the bridging.
// Hence the name "shimOnlyBridge".
let sdsTx = SDSDB.shimOnlyBridge(transaction)
return BarFinder.getBar(transaction: sdsTx)
}
}
private class FooBarManager_BarFinderMock: FooBarManager_BarFinderShim {
init() {}
var barToReturn: Bar?
func getBar(transaction: DBReadTransaction) -> Bar? {
// Again we ignore the transaction, like in FooFinderMock.
return barToReturn
}
}
// Aside: Holy TeenageMutantNinjaTurtles Batman! Those are some long and ugly names!
// Some namespacing can make things easier to read. Swift doesn't allow protocols
// inside other declarations (they have to live at the top level) but we can
// use typealias to get the same result:
// enum with no cases is a great way to create a no-op name for namespace purposes only.
private enum FooBar {
enum Shims {
typealias BarFinder = FooBarManager_BarFinderShim
}
enum Wrappers {
typealias BarFinder = FooBarManager_BarFinderWrapper
}
enum Mocks {
typealias BarFinder = FooBarManager_BarFinderMock
}
}
// Now finally we can update FooBarManager to accept a mockable dependency on
// FooFinder, like it does for FooFinder:
private class FooBarManager3 {
let fooFinder: FooFinderProtocol
let barFinder: FooBar.Shims.BarFinder
init(
fooFinder: FooFinderProtocol,
barFinder: FooBar.Shims.BarFinder
) {
self.fooFinder = fooFinder
self.barFinder = barFinder
}
func doTheFooBar(transaction: DBReadTransaction) {
_ = fooFinder.getFoo(transaction: transaction)!
_ = barFinder.getBar(transaction: transaction)!
// doTheFooBar(foo: foo, bar: bar)
}
}
// We create a "real" instance like so:
func ex2() {
_ = FooBarManager3(
fooFinder: FooFinder(),
barFinder: FooBar.Wrappers.BarFinder()
)
}
// And everything is stubbed out in tests, with no dependencies
// we don't control:
class FooBarManagerTest3: XCTestCase {
private var fooFinderMock: FooFinderMock!
private var barFinderMock: FooBar.Mocks.BarFinder!
private var fooBarManager: FooBarManager3!
override func setUp() {
fooFinderMock = FooFinderMock()
barFinderMock = FooBar.Mocks.BarFinder()
fooBarManager = FooBarManager3(fooFinder: fooFinderMock, barFinder: barFinderMock)
}
func testGetFooAndBar_2() throws {
throw XCTSkip("Don't actually want to run this test")
#if false
let db = InMemoryDB()
fooFinderMock.fooToReturn = Foo(text: "Hello, world!")
barFinderMock.barToReturn = Bar(num: 42)
db.read { transaction in
fooBarManager.doTheFooBar(transaction: transaction)
}
// Assert stuff...
#endif
}
}
// To reiterate:
// We used DB and its transaction classes instead of SDS classes,
// so that we can easily generate fake instances with the lightweight,
// dependency-free MockDB.
// We use SDSDB.shimOnlyBridge to convert between our db-agnostic code
// that talks in DB types, and dependencies that talk in SDS types.
// These dependencies can be:
//
// * New classes, like FooFinder, that are the final point of use that
// unwrap the transaction to utilize the db connection.
//
// * Old classes, not explicitly demonstrated here, but these would
// be things like TSAccountManager (at time of writing) which may not
// access the db connection themselves, but which haven't been refactored
// to use the new types yet.
// In either case, we need to mock out those classes so that we never call
// SDSDB.shimOnlyBridge in tests; doing so will cause a crash.
// There are two options for this, depending on how much effort is required
// and the time available to undergo that effort:
//
//
// The Good Way: avoid static methods, always wrap your classes in a protocol,
// andd avoid objective-C compatibility requirements.
// This lets you accept DB transaction types and create simple Mock implementations.
//
// This is how FooFinder works in this example.
//
//
// The Shortcut Way: create a {ClassName}_{DependencyName}Shim protocol and Wrapper class
// that are used only by your class and which contain only those methods your class invokes.
// This keeps the scope of changes smaller, leaving the original class entirely untouched.
//
// This is how BarFinder works in this example.
//
// The Good Way is _always_ preferred, but requires more time to refactor existing code to do it.
// Depending on the class, and how intertwined it is with objc code, it might require
// a LOT more time.
// In those cases, the Shortcut Way is at least better than giving up on testing entirely.
// Use at your discretion, and lean on code review to get feedback on the right approach.