Skip to content

Instantly share code, notes, and snippets.

@turekj
Last active January 2, 2020 11:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save turekj/1fa5f5024c40a2946c0fda71dc393920 to your computer and use it in GitHub Desktop.
Save turekj/1fa5f5024c40a2946c0fda71dc393920 to your computer and use it in GitHub Desktop.
Separating Decisions and Side-Effects: After the Separation, Handling Additional States
// 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