3

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
}
Hans Bondoka
  • 437
  • 1
  • 4
  • 14
  • 1
    Generally speaking one does not test methods, but object behavior. Eg. if I have a calculator class, which handles all kinds of computations using private methods, I don't care how the class does it as long as it returns 6 when I call `add(2,4)`. This applies to any OO code. Another thing from among the [SOLID](https://en.wikipedia.org/wiki/SOLID) is Dependency Injection. If you wish to have your code testable, the dependencies should be injectable, so you can for instance test the class properly. Because your code violates this principle, it's hard for you to properly test it. – Mike Doe Mar 08 '19 at 17:53
  • Thank you for clarification @emix. What I want to test is the `state` variable. For the test I want to set my inputs and see if the output (states) are correct. This is just a simple code example, in reality the states are much more complex. – Hans Bondoka Mar 08 '19 at 17:59
  • Thus the state should be injected so you can reverse the dependency when unit testing. – Mike Doe Mar 08 '19 at 18:00

2 Answers2

4

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.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • Brilliant solution, thanks! Is it necessary to define `var storage` in `class GameStatus` as static due to your mentioned value semantics? – Hans Bondoka Mar 08 '19 at 20:56
  • 1
    Making it static would not provide value semantics, though it could make the reference semantics *always* be true. (If you were doing that, though, I'd probably just require GameStorage itself to be a class; which is somewhat sensible for a storage type.) You could also move the storage outside of GameStatus entirely, and pass it in via `restore(from: GameStorage)` and `save(to: GameStorage)`. Then the value/reference semantics are not GameState's problem. – Rob Napier Mar 08 '19 at 21:51
  • Ok, I got it! One more thing I’m not sure about: When I save something to `UserDefaults` then iOS typically needs a couple of seconds until it writes it to disk. When I read from `UserDefaults` from another e.g. class do I immediately get the new value? – Hans Bondoka Mar 08 '19 at 22:10
  • 1
    Yes. Writes to UserDefaults are promised to be seen by all later readers in your process, even in a multi-threaded environment. "When you set a default value, it’s changed synchronously within your process, and asynchronously to persistent storage and other processes…The UserDefaults class is thread-safe." – Rob Napier Mar 09 '19 at 14:54
2

The solution is Dependency Injection!

You can create a Persisting protocol and a facade class to interact with the user defaults

protocol Persisting {
  func getObject(key: String) -> Any?
  func persist(value: Any, key: String)
}

final class Persist: Persisting {
  func getObject(key: String) -> Any? {
    return UserDefaults.standard.object(forKey: key)
  }

  func persist(object: Any, key: String) {
    UserDefaults.standard.set(value: object, forKey: key)
  }
}

class MockPersist: Persisting {
  // this is set from the test
  var mockObjectToReturn: Any?
  func getObject(key: String) -> Any? {
    return mockObjectToReturn
  }

  var didCallPersistObject: (Any?, String)
  func persist(object: Any, key: String) {
    didCallPersistObject.0 = object
    didCallPersistObject.1 = key
  }
}

And now on you struct, you gonna need to inject this a var of type Persisting.

When testing you gonna need to inject the MockPersist and assert against the vars defined on the MockPersist class.

Hope this helps

dmlebron
  • 861
  • 6
  • 16
  • it does help, but ingores part of the question: how to keep struct and test against it. I guess it is impossible/doesn't make any sense. – Async- Oct 02 '21 at 18:57