Last active
January 3, 2020 22:22
-
-
Save adam-zethraeus/34d2aa882c106d8f44ecda6a976361db to your computer and use it in GitHub Desktop.
A bad, stateful, and impossible to unit test, 'onboarding manger'.
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
- |
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
import Foundation | |
protocol OnboardingManagerListener: AnyObject { | |
func onboardingManagerReturned(profile: Profile) | |
func onboardingManagerFailed() | |
} | |
class BadOnboardingManager: UsernameCollectorListener, EmailCollectorListener, PhoneNumberCollectorListener, PasswordCollectorListener { | |
weak var listener: OnboardingManagerListener? | |
private let passwordCollectorLauncher: PasswordCollectorLauncher | |
init(passwordCollectorLauncher: PasswordCollectorLauncher) { | |
self.passwordCollectorLauncher = passwordCollectorLauncher | |
// TODO: inject username, email, and phone number collector launchers. | |
} | |
// Stored Data | |
private var username: String? | |
private var email: String? | |
private var phoneNumber: String? | |
private var password: String? | |
private var passwordIsValid: Bool = false | |
private var passwordIssue: PasswordIssue? | |
func start() { | |
// 0: collect username using `launchUsernameCollector`. | |
// once `usernameCollectorReturned` calls, continue. | |
// 1: collect using with `launchEmailCollector`. | |
// once `emailCollectorReturned` calls, continue. | |
// 2: collect phone number using `launchPhoneNumberCollector`. | |
// once `phoneNumberCollectorReturned` calls, continue. | |
// 3: collect potential password using `launchPasswordCollector`. | |
// once `passwordCollectorReturned` calls, continue. | |
// NOTE: 4-6 are implemented as `attemptFinish`. | |
// 4: if any collectors failed, message failure to our listener | |
// 5: if potential password is invalid go to (3). | |
// 6: construct Profile object and send it to our listener. | |
} | |
private func attemptFinish() { | |
guard let username = self.username, | |
let email = self.email, | |
let phoneNumber = self.phoneNumber, | |
let password = self.password | |
else { | |
listener?.onboardingManagerFailed() | |
return | |
} | |
validatePassword() | |
if passwordIsValid { | |
listener?.onboardingManagerReturned(profile: Profile(username: username, | |
email: email, | |
phoneNumber: phoneNumber, | |
password: password)) | |
} else { | |
// Launch the password collector again with a for a better password. | |
passwordCollectorLauncher.launch(existingPasswordIssue: passwordIssue) | |
} | |
} | |
private func validatePassword() { | |
passwordIsValid = false | |
passwordIssue = nil | |
guard let username = self.username, | |
let email = self.email, | |
let phoneNumber = self.phoneNumber, | |
let password = self.password | |
else { | |
return | |
} | |
if password.count < 10 { | |
passwordIssue = .tooShort | |
} | |
if password.contains(username) { | |
passwordIssue = .containsUsername | |
} | |
if password.contains(email) { | |
passwordIssue = .containsEmail | |
} | |
if password.contains(phoneNumber) { | |
passwordIssue = .containsPhoneNumber | |
} | |
if !passwordContainsAtLeastOneNumber() { | |
passwordIssue = .noNumbers | |
} | |
if !passwordContainsAtLeastOneSpecialCharacter() { | |
passwordIssue = .noSpecialCharacters | |
} | |
passwordIsValid = true | |
} | |
// Listener callbacks | |
func passwordCollectorReturned(password: String){ | |
attemptFinish() | |
} | |
func passwordCollectorFailed(){ | |
listener?.onboardingManagerFailed() | |
} | |
// Sub-actions which trigger user input and listener callbacks | |
private func launchPasswordCollector(){ | |
passwordCollectorLauncher.launch(existingPasswordIssue: nil) | |
} | |
// TODO: Unimplemented Password validation helpers | |
private func passwordContainsAtLeastOneNumber() -> Bool { fatalError("unimplemented") } | |
private func passwordContainsAtLeastOneSpecialCharacter() -> Bool { fatalError("unimplemented") } | |
// TODO: Unimplemented Sub-actions which trigger user input and listener callbacks | |
private func launchUsernameCollector(){ fatalError("unimplemented") } | |
private func launchEmailCollector(){ fatalError("unimplemented") } | |
private func launchPhoneNumberCollector(){ fatalError("unimplemented") } | |
// TODO: Unimplemented Listener callbacks | |
func usernameCollectorReturned(username: String){ fatalError("unimplemented")} | |
func usernameCollectorFailed(){ fatalError("unimplemented") } | |
func emailCollectorReturned(email: String){ fatalError("unimplemented") } | |
func emailCollectorFailed(){ fatalError("unimplemented") } | |
func phoneNumberCollectorReturned(number: String){ fatalError("unimplemented") } | |
func phoneNumberCollectorFailed(){ fatalError("unimplemented") } | |
func onboardingManagerListenerReturned(profile: Profile){ fatalError("unimplemented") } | |
func onboardingManagerListenerFailed(){ fatalError("unimplemented") } | |
} |
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
import XCTest | |
class BadOnboardingManagerTests: XCTestCase { | |
func test_OnboardingManager() { | |
let mockPasswordCollectorLauncher = MockPasswordCollectorLauncher() | |
let mockListener = MockOnboardingManagerListener() | |
let onboardingManager = BadOnboardingManager(passwordCollectorLauncher: mockPasswordCollectorLauncher) | |
onboardingManager.listener = mockListener | |
onboardingManager.start() | |
// TODO: assert the username collector launches now. | |
onboardingManager.usernameCollectorReturned(username: "ValidUsername") | |
// TODO: assert the email collector launches now. | |
onboardingManager.emailCollectorReturned(email: "valid@example.com") | |
// TODO: assert the phone number collector launches now. | |
XCTAssertEqual(mockPasswordCollectorLauncher.launchCallCount, 0) | |
onboardingManager.phoneNumberCollectorReturned(number: "+11235554321") | |
XCTAssertEqual(mockPasswordCollectorLauncher.launchCallCount, 1) | |
XCTAssertEqual(mockPasswordCollectorLauncher.existingPasswordIssueValues.last, nil) | |
onboardingManager.passwordCollectorReturned(password: "ValidUsername$123") | |
XCTAssertEqual(mockPasswordCollectorLauncher.launchCallCount, 2) | |
XCTAssertEqual(mockPasswordCollectorLauncher.existingPasswordIssueValues.last, PasswordIssue.containsUsername) | |
onboardingManager.passwordCollectorReturned(password: "Password123") | |
XCTAssertEqual(mockPasswordCollectorLauncher.launchCallCount, 3) | |
XCTAssertEqual(mockPasswordCollectorLauncher.existingPasswordIssueValues.last, PasswordIssue.noSpecialCharacters) | |
XCTAssertEqual(mockListener.onboardingManagerReturnedCallCount, 0) | |
onboardingManager.passwordCollectorReturned(password: "ValidPassword$123") | |
XCTAssertEqual(mockListener.onboardingManagerReturnedCallCount, 1) | |
} | |
} |
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
import Foundation | |
protocol OnboardingManagerListener: AnyObject { | |
func onboardingManagerReturned(profile: Profile) | |
func onboardingManagerFailed() | |
} | |
class OnboardingManager: UsernameCollectorListener, EmailCollectorListener, PhoneNumberCollectorListener, PasswordCollectorListener { | |
weak var listener: OnboardingManagerListener? | |
private let passwordCollectorLauncher: PasswordCollectorLauncher | |
init(passwordCollectorLauncher: PasswordCollectorLauncher) { | |
self.passwordCollectorLauncher = passwordCollectorLauncher | |
// TODO: inject username, email, and phone number collector launchers. | |
} | |
// Stored Data | |
private var username: String? | |
private var email: String? | |
private var phoneNumber: String? | |
private var password: String? | |
func start() { | |
// 0: collect username using `launchUsernameCollector`. | |
// once `usernameCollectorReturned` calls, continue. | |
// 1: collect using with `launchEmailCollector`. | |
// once `emailCollectorReturned` calls, continue. | |
// 2: collect phone number using `launchPhoneNumberCollector`. | |
// once `phoneNumberCollectorReturned` calls, continue. | |
// 3: collect potential password using `launchPasswordCollector`. | |
// once `passwordCollectorReturned` calls, continue. | |
// NOTE: 4-6 are implemented as `attemptFinish`. | |
// 4: if any collectors failed, message failure to our listener | |
// 5: if potential password is invalid go to (3). | |
// 6: construct Profile object and send it to our listener. | |
} | |
private func attemptFinish() { | |
guard let username = self.username, | |
let email = self.email, | |
let phoneNumber = self.phoneNumber, | |
let password = self.password | |
else { | |
listener?.onboardingManagerFailed() | |
return | |
} | |
let (passwordIsValid, passwordIssue) = OnboardingManager.validatePassword(password: password, username: username, email: email, phoneNumber: phoneNumber) | |
if passwordIsValid { | |
listener?.onboardingManagerReturned(profile: Profile(username: username, | |
email: email, | |
phoneNumber: phoneNumber, | |
password: password)) | |
} else { | |
// Launch the password collector again with a for a better password. | |
passwordCollectorLauncher.launch(existingPasswordIssue: passwordIssue) | |
} | |
} | |
static func validatePassword(password: String, username: String, email: String, phoneNumber: String) -> (Bool, PasswordIssue?){ | |
if password.count < 10 { | |
return (false, .tooShort) | |
} | |
if password.contains(username) { | |
return (false, .containsUsername) | |
} | |
if password.contains(email) { | |
return (false, .containsEmail) | |
} | |
if password.contains(phoneNumber) { | |
return (false, .containsPhoneNumber) | |
} | |
if !passwordContainsAtLeastOneNumber(password) { | |
return (false, .noNumbers) | |
} | |
if !passwordContainsAtLeastOneSpecialCharacter(password) { | |
return (false, .noSpecialCharacters) | |
} | |
return (true, nil) | |
} | |
// Listener callbacks | |
func passwordCollectorReturned(password: String){ | |
attemptFinish() | |
} | |
func passwordCollectorFailed(){ | |
listener?.onboardingManagerFailed() | |
} | |
// Sub-actions which trigger user input and listener callbacks | |
private func launchPasswordCollector(){ | |
passwordCollectorLauncher.launch(existingPasswordIssue: nil) | |
} | |
// TODO: Unimplemented Password validation helpers | |
static func passwordContainsAtLeastOneNumber(_ password: String) -> Bool { fatalError("unimplemented") } | |
static func passwordContainsAtLeastOneSpecialCharacter(_ password: String) -> Bool { fatalError("unimplemented") } | |
// TODO: Unimplemented Sub-actions which trigger user input and listener callbacks | |
private func launchUsernameCollector(){ fatalError("unimplemented") } | |
private func launchEmailCollector(){ fatalError("unimplemented") } | |
private func launchPhoneNumberCollector(){ fatalError("unimplemented") } | |
// TODO: Unimplemented Listener callbacks | |
func usernameCollectorReturned(username: String){ fatalError("unimplemented")} | |
func usernameCollectorFailed(){ fatalError("unimplemented") } | |
func emailCollectorReturned(email: String){ fatalError("unimplemented") } | |
func emailCollectorFailed(){ fatalError("unimplemented") } | |
func phoneNumberCollectorReturned(number: String){ fatalError("unimplemented") } | |
func phoneNumberCollectorFailed(){ fatalError("unimplemented") } | |
func onboardingManagerListenerReturned(profile: Profile){ fatalError("unimplemented") } | |
func onboardingManagerListenerFailed(){ fatalError("unimplemented") } | |
} |
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
import XCTest | |
class BetterOnboardingManagerTests: XCTestCase { | |
func testPasswordCanNotContainUsername() { | |
let (didPass, issue) = OnboardingManager.validatePassword(password: "ValidUsername$123", | |
username: "ValidUsername", | |
email: "valid@example.com", | |
phoneNumber: "+11235554321") | |
XCTAssertFalse(didPass) | |
XCTAssertEqual(issue, PasswordIssue.containsUsername) | |
} | |
func testPasswordMustHaveSpecialChar() { | |
let (didPass, issue) = OnboardingManager.validatePassword(password: "Password123", | |
username: "ValidUsername", | |
email: "valid@example.com", | |
phoneNumber: "+11235554321") | |
XCTAssertFalse(didPass) | |
XCTAssertEqual(issue, PasswordIssue.noSpecialCharacters) | |
} | |
func testValidPassword() { | |
let (didPass, issue) = OnboardingManager.validatePassword(password: "ValidUsername", | |
username: "Password$123", | |
email: "valid@example.com", | |
phoneNumber: "+11235554321") | |
XCTAssertFalse(didPass) | |
XCTAssertEqual(issue, PasswordIssue.noSpecialCharacters) | |
} | |
} |
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
protocol UsernameCollectorListener { | |
func usernameCollectorReturned(username: String) | |
func usernameCollectorFailed() | |
} | |
protocol EmailCollectorListener { | |
func emailCollectorReturned(email: String) | |
func emailCollectorFailed() | |
} | |
protocol PhoneNumberCollectorListener { | |
func phoneNumberCollectorReturned(number: String) | |
func phoneNumberCollectorFailed() | |
} | |
protocol PasswordCollectorListener { | |
func passwordCollectorReturned(password: String) | |
func passwordCollectorFailed() | |
} | |
struct Profile { | |
let username: String | |
let email: String | |
let phoneNumber: String | |
let password: String | |
} | |
protocol PasswordCollectorLauncher { | |
func launch(prompt: String?) | |
} |
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
class MockOnboardingManagerListener: OnboardingManagerListener { | |
var onboardingManagerReturnedCallCount = 0 | |
var onboardingManagerReturnedProfileValues = [Profile]() | |
func onboardingManagerReturned(profile: Profile) { | |
onboardingManagerReturnedCallCount += 1 | |
onboardingManagerReturnedProfileValues.append(profile) | |
} | |
var onboardingManagerFailedCallCount = 0 | |
func onboardingManagerFailed() { | |
onboardingManagerFailedCallCount += 1 | |
} | |
} | |
class MockPasswordCollectorLauncher: PasswordCollectorLauncher { | |
var launchCallCount = 0 | |
var existingPasswordIssueValues = [PasswordIssue?]() | |
func launch(existingPasswordIssue: PasswordIssue?) { | |
launchCallCount += 1 | |
existingPasswordIssueValues.append(existingPasswordIssue) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment