Search code examples
swiftunit-testingxctestxctestcase

How to Unit-Test with global structs?


It is clear for me that in a UnitTest you

  1. generate an input property
  2. pass this property to the method you want to test
  3. Compare the results with your expected results

However, what if you have a global struct with e.g. the game xp and game level which has private setters and can't be modified. I automatically load this data from the UserDefaults when the app starts. How can you test methods that access that global struct, when you can not alter the input?

Example:

import UIKit

//Global struct with private data
struct GameStatus {
    private(set) static var xp: Int = 0
    private(set) static var level: Int = 0

    /// Holds all winning states
    enum MyGameStatus {
        case hasNotYetWon
        case hasWon
    }

    /// Today's game state of the user against ISH
    static var todaysGameStatus: MyGameStatus {
        if xp >= 100 {
            return .hasWon
        } else {
            return .hasNotYetWon
        }
    }

    func restoreXpAndLevel() {
        // reads UserData value
    }

    func increaseXp(for: Int) {
        //...
    }
}

// class with methods to test
class LevelView: UIView {

    enum LevelState {
        case showStart
        case showCountdown
        case showFinalCuontdown
    }

    var state: LevelState {
        if GameStatus.xp > 95 {
            return .showFinalCuontdown
        } else if GameStatus.xp > 90 {
            return .showCountdown
        }
        return .showStart
    }

    //...configurations depending on the level
}

Solution

  • First, LevelView looks like it has too much logic in it. The point of a view is to display model data. It's not to include business logic like GameStatus.xp > 95. That should be done elsewhere and set into the view.

    Next, why is GameStatus static? This is just complicating this. Pass the GameStatus to the view when it changes. That's the job of the view controller. Views just draw stuff. If anything is really unit-testable in your view, it probably shouldn't be in a view.

    Finally, the piece that you're struggling with is the user defaults. So extract that piece into a generic GameStorage.

    protocol GameStorage {
        var xp: Int { get set }
        var level: Int { get set }
    }
    

    Now make UserDefaults a GameStorage:

    extension UserDefaults: GameStorage {
        var xp: Int {
            get { /* Read from UserDefaults */ return ... }
            set {  /* Write to UserDefaults */ }
        }
        var level: Int {
            get { /* Read from UserDefaults */ return ... }
            set {  /* Write to UserDefaults */ }
        }
    }
    

    And for testing, create a static one:

    struct StaticGameStorage: GameStorage {
        var xp: Int
        var level: Int
    }
    

    Now when you create a GameStatus, pass it storage. But you can give that a default value, so you don't have to pass it all the time

    class GameStatus {
        private var storage: GameStorage
    
        // A default parameter means you don't have to pass it normally, but you can
        init(storage: GameStorage = UserDefaults.standard) {
            self.storage = storage
        }
    

    With that, xp and level can just pass through to storage. No need for a special "load the storage now" step.

    private(set) var xp: Int {
        get { return storage.xp }
        set { storage.xp = newValue }
    }
    private(set) var level: Int {
        get { return storage.level }
        set { storage.level = newValue }
    }
    

    EDIT: I made a change here from GameStatus being a struct to a class. That's because GameStatus lacks value semantics. If there are two copies of GameStatus, and you modify one of them, the other may change, too (because they both write to UserDefaults). A struct without value semantics is dangerous.

    It's possible to regain value semantics, and it's worth considering. For example, instead of passing through xp and level to the storage, you could go back to your original design that has an explicit "restore" step that loads from storage (and I assume a "save" step that writes to storage). Then GameStatus would be an appropriate struct.


    I'd also extract LevelState so that you can more easily test it and it captures the business logic outside of the view.

    enum LevelState {
        case showStart
        case showCountdown
        case showFinalCountDown
        init(xp: Int) {
            if xp > 95 {
                self = .showFinalCountDown
            } else if xp > 90 {
                self = .showCountdown
            }
            self = .showStart
        }
    }
    

    If this is only ever used by this one view, it's fine to nest it. Just don't make it private. You can test LevelView.LevelState without having to do anything with LevelView itself.

    And then you can update the view's GameStatus as you need to:

    class LevelView: UIView {
    
        var gameStatus: GameStatus? {
            didSet {
                // Refresh the view with the new status
            }
        }
    
        var state: LevelState {
            guard let xp = gameStatus?.xp else { return .showStart }
            return LevelState(xp: xp)
        }
    
        //...configurations depending on the level
    }
    

    Now the view itself doesn't need logic testing. You might do image-based testing to make sure it draws correctly given different inputs, but that's completely end-to-end. All the logic is simple and testable. You can test GameStatus and LevelState without UIKit at all by passing a StaticGameStorage to GameStatus.