Separating Decisions and Side-Effects: After the Separation, Handling Additional States
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// See article at: https://jakubturek.com/separating-decisions-and-effects/ | |
import Foundation | |
import Quick | |
import Nimble | |
// MARK: - Data models | |
struct UserToken: Codable { | |
let token: String | |
enum CodingKeys: String, CodingKey { | |
case token = "session_token" | |
} | |
} | |
struct ValidationErrors: Codable { | |
let errors: [String] | |
enum CodingKeys: String, CodingKey { | |
case errors = "validation_errors" | |
} | |
} | |
struct DummyError: Error, LocalizedError { | |
let errorDescription: String? | |
init(_ errorDescription: String) { | |
self.errorDescription = errorDescription | |
} | |
} | |
// MARK: - Dependencies | |
protocol SimpleService { | |
typealias Completion = (Result<String, Error>) -> Void | |
func call(_ completion: @escaping Completion) | |
} | |
protocol AlertPresenter { | |
func present(error: String) | |
} | |
protocol TokenStore { | |
func set(sessionToken: String) | |
} | |
// MARK: - Spies | |
class SimpleServiceSpy: SimpleService { | |
var callInvoked: SimpleService.Completion? | |
func call(_ completion: @escaping SimpleService.Completion) { | |
callInvoked = completion | |
} | |
} | |
class AlertPresenterSpy: AlertPresenter { | |
var presentInvoked: String? | |
func present(error: String) { | |
presentInvoked = error | |
} | |
} | |
class TokenStoreSpy: TokenStore { | |
var setSessionTokenInvoked: String? | |
func set(sessionToken: String) { | |
setSessionTokenInvoked = sessionToken | |
} | |
} | |
// MARK: - System under the test | |
class SimpleViewController: UIViewController { | |
let service: SimpleService | |
let alertPresenter: AlertPresenter | |
let tokenStore: TokenStore | |
let validationErrorLabel = UILabel() | |
init(service: SimpleService, alertPresenter: AlertPresenter, tokenStore: TokenStore) { | |
self.service = service | |
self.alertPresenter = alertPresenter | |
self.tokenStore = tokenStore | |
super.init(nibName: nil, bundle: nil) | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
service.call { [weak self] in self?.dispatch(action: action(for: $0)) } | |
} | |
func dispatch(action: SimpleViewControllerAction) { | |
switch action { | |
case let .showAlert(error): | |
alertPresenter.present(error: error) | |
case let .showValidationError(error): | |
validationErrorLabel.text = error | |
case let .storeToken(sessionToken): | |
tokenStore.set(sessionToken: sessionToken) | |
} | |
} | |
required init?(coder: NSCoder) { nil } | |
} | |
// MARK: - Actions | |
enum SimpleViewControllerAction: Equatable { | |
case showAlert(message: String) | |
case showValidationError(error: String) | |
case storeToken(sessionToken: String) | |
} | |
func action(for result: Result<String, Error>) -> SimpleViewControllerAction { | |
switch result { | |
case let .success(json): | |
return action(forJSON: json) | |
case let .failure(error): | |
return .showAlert(message: error.localizedDescription) | |
} | |
} | |
func action(forJSON json: String) -> SimpleViewControllerAction { | |
let error: ValidationErrors? = decodeJSON(from: json) | |
let session: UserToken? = decodeJSON(from: json) | |
switch (error, session) { | |
case let (.some(error), .none): | |
let message = error.errors.joined(separator: "\n") | |
return .showValidationError(error: message) | |
case let (.none, .some(session)): | |
return .storeToken(sessionToken: session.token) | |
case (.none, .none), (.some, .some): | |
return .showAlert(message: "Unexpected response received") | |
} | |
} | |
func decodeJSON<Value: Decodable>(from json: String) -> Value? { | |
let data = Data(json.utf8) | |
return try? JSONDecoder().decode(Value.self, from: data) | |
} | |
// MARK: - The test method | |
func encodeJSON<Value: Encodable>(_ value: Value) -> String { | |
let data = try! JSONEncoder().encode(value) | |
return String(data: data, encoding: .utf8)! | |
} | |
class SimpleViewControllerSpec: QuickSpec { | |
override func spec() { | |
describe("SimpleViewController") { | |
var service: SimpleServiceSpy! | |
var alertPresenter: AlertPresenterSpy! | |
var tokenStore: TokenStoreSpy! | |
var sut: SimpleViewController! | |
beforeEach { | |
service = SimpleServiceSpy() | |
alertPresenter = AlertPresenterSpy() | |
tokenStore = TokenStoreSpy() | |
sut = SimpleViewController(service: service, alertPresenter: alertPresenter, tokenStore: tokenStore) | |
} | |
afterEach { | |
tokenStore = nil | |
service = nil | |
alertPresenter = nil | |
sut = nil | |
} | |
context("when view is loaded") { | |
beforeEach { | |
_ = sut.view | |
} | |
context("when the error is returned") { | |
beforeEach { | |
service.callInvoked?(.failure(DummyError("You're offline!"))) | |
} | |
it("should present error alert") { | |
expect(alertPresenter.presentInvoked).toNot(beNil()) | |
expect(alertPresenter.presentInvoked) == "You're offline!" | |
} | |
} | |
describe("dispatch action") { | |
context("with .showAlert action") { | |
beforeEach { | |
sut.dispatch(action: .showAlert(message: "You're offline")) | |
} | |
it("should present error alert") { | |
expect(alertPresenter.presentInvoked).toNot(beNil()) | |
expect(alertPresenter.presentInvoked) == "You're offline" | |
} | |
} | |
context("with .showValidationError action") { | |
beforeEach { | |
sut.dispatch(action: .showValidationError(error: "Validation error")) | |
} | |
it("should update the error label") { | |
expect(sut.validationErrorLabel.text).toNot(beNil()) | |
expect(sut.validationErrorLabel.text) == "Validation error" | |
} | |
} | |
context("with .storeToken action") { | |
beforeEach { | |
sut.dispatch(action: .storeToken(sessionToken: "session_token")) | |
} | |
it("should update the session token label") { | |
expect(tokenStore.setSessionTokenInvoked).toNot(beNil()) | |
expect(tokenStore.setSessionTokenInvoked) == "session_token" | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
class ActionForRequestResultSpec: QuickSpec { | |
override func spec() { | |
describe("action for request result") { | |
context("with successful validation error result") { | |
it("should return .showValidationError action") { | |
let json = ValidationErrors(errors: ["First", "Second", "Third"]) | |
let result = action(for: .success(encodeJSON(json))) | |
expect(result) == .showValidationError(error: "First\nSecond\nThird") | |
} | |
} | |
context("with successful user token result") { | |
it("should return .setTokenAction action") { | |
let json = UserToken(token: "session_token") | |
let result = action(for: .success(encodeJSON(json))) | |
expect(result) == .storeToken(sessionToken: "session_token") | |
} | |
} | |
context("with failure result") { | |
it("should return .showAlert action") { | |
let result = action(for: .failure(DummyError("Non-recoverable"))) | |
expect(result) == .showAlert(message: "Non-recoverable") | |
} | |
} | |
context("with successful unexpected result") { | |
it("should return .showAlert action") { | |
let result = action(for: .success("Not a real success")) | |
expect(result) == .showAlert(message: "Unexpected response received") | |
} | |
} | |
context("with successful mixed result") { | |
var result: ValidationTokenCombined! | |
beforeEach { | |
result = ValidationTokenCombined( | |
errors: ValidationErrors(errors: ["Error"]), | |
token: UserToken(token: "token") | |
) | |
} | |
afterEach { | |
result = nil | |
} | |
it("should return .showAlert action") { | |
let result = action(for: .success(encodeJSON(result))) | |
expect(result) == .showAlert(message: "Unexpected response received") | |
} | |
} | |
} | |
} | |
} | |
struct ValidationTokenCombined: Codable { | |
let errors: ValidationErrors | |
let token: UserToken | |
func encode(to encoder: Encoder) throws { | |
try errors.encode(to: encoder) | |
try token.encode(to: encoder) | |
} | |
} | |
class ValidationTokenCombinedSpec: QuickSpec { | |
override func spec() { | |
describe("ValidationTokenCombined") { | |
var sut: ValidationTokenCombined! | |
beforeEach { | |
sut = ValidationTokenCombined( | |
errors: ValidationErrors(errors: ["Error"]), | |
token: UserToken(token: "token") | |
) | |
} | |
afterEach { | |
sut = nil | |
} | |
it("should be correctly serialized") { | |
expect(encodeJSON(sut)) | |
== """ | |
{"session_token":"token","validation_errors":["Error"]} | |
""" | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment