Test-Driven Development: #5 Refactoring Validation
Now that it's all green and checkmark-y, let's start improving our structure a bit!
So far it's been nothing but red to green (and fixing some compile errors), but less refactoring, I think with the current concepts discussed in the previous posts, we can safely start doing some refactoring and see how can we improve upon our current structure!
Introduction
Howdy, everyone! ๐
Been a journey thru the realms of TDD that was full of green and checkmarks!, actually coming to revise it, we now have 4 tests, each teaching us something, but today, instead of learning more knowledge, let's take this post today into reinforcing what we've learned, hence, let's better our structure, and with tests in place, we know that if something break, they would tell us!
Alright, load up your toolkit cuz we got some structure to improve!
First things first, what do I don't like about the current structure?
Gotta be a reason for refactoring things, right?
- Our validations, they are not OCP friendly
- Our error handling needs to be more streamlined and more organized
- Test Coverage, this can be improved, and we will see how
So let's go from easy to difficult
Improving Validation Structure
Problem
Currently, we are hardcoding behavior with what we're trying to validate, like here
public enum ValidationError: Error {
case emailIsEmpty
}
While this is ok... but in Validations, in its nature, there is some sort of abstraction among requirements
You will see that, something
can't be empty, something else
can't be invalid, something totally different
must have this... etc
What if we dealt with email
as something
, and suddenly, we are testing passwords, phone numbers, emails, and any future field? wouldn't that be nicer?
so let's take this one step at a time
Improving OCP
Let's say we wanted to centralize our Business Configurations, y'know, those stuff like email can't be empty, a password can't be less than 8 characters, and so on...
Checking our requirements
Let's first get what the business needs in the validation requirements and then we can go on
Login Local Validation Requirements
1. Email
- Mandatory
- Must be Valid
- Can be limited to 999 characters in the field
2. Password
- Mandatory
- Must have at least 8 characters
- Must contain at least 1 special character
- Must have upper and lowercase character
Coming from these requirements, we can actually translate them into a centralized enum and start giving it some specs, starting off with something like this
/// Represents Business Logic Configuration for easier changes whether global or specific changes
public enum BusinessConfigurations {
public enum Validation {}
}
public extension BusinessConfigurations.Validation {
enum Field {
case email
case password
var minMax: (min: Int, max: Int) {
switch self {
case .email:
return (0, 999)
case .password:
return (8, 32)
}
}
var title: String {
switch self {
case .email:
return "Email"
case .password:
return "Password"
}
}
}
}
Now, the next step is to edit our ValidationError
enum to accept Field
rather than hardcoding what is the error
public enum ValidationError: Error {
case empty(BusinessConfigurations.Validation.Field) <- Changed
}
extension ValidationError: LocalizedError {
public var errorDescription: String? {
switch self {
case let .empty(field): <- Changed
return "\(field.title) can't be empty" <- Changed
}
}
}
extension ValidationError: Equatable { }
Now, instead of saying, emptyPassword
, emptyEmail
, I can just throw an error like...
guard email.isEmpty == false else { throw ValidationError.empty(.email) }
// More validations...
Now, let's try to fix the compile errors, let's visit our LoginViewModel again
func login(email: String, password: String) {
if email.isEmpty {
// validationErrors.append(.emailIsEmpty) <- Removed
validationErrors.append(.empty(.email)) <- Added
}
// More code ahead...
Well, an improvement, but there is still a lot, first let's adapt our testing code, and get back here for more refactoring
func test_GivenEmptyEmail_WhenLogin_ShowError() {
// Given
let email = ""
let password = ""
// When
sut.login(email: email, password: password)
// Then
XCTAssertTrue(sut.validationErrors.contains(.empty(.email)))
// โ๏ธ Changed
}
func test_GivenAnError_WhenLogin_OnErrorIsCalled() {
// Given
let email = ""
let password = ""
// When
sut.onError = { errors in
// Then
XCTAssertEqual(errors.first!.localizedDescription, ValidationError.empty(.email).localizedDescription)
// โ๏ธ Changed
}
sut.login(email: email, password: password)
}
Build & Test, and now the green checkmarks are back!
Now let's look at our login
method inside the LoginViewModel
Demo
There is a lot to improve here, for starters, it's not complying with the Single Responsibility Principle, it has side effects and it can definitely be broken down because it's not just logging the user in, it's validating and checking before actually logging in
So, how about we start by breaking it down into something smaller and focused?
Let's do so, only this time, I'll be recording some quick videos since writing might not be the most digestible way in demo-ing this.
Conclusion
Phew First time doing live coding and boy that was stressful editing
Well, at least it's done now and what matters the most is delivering benefit to whoever reads or watches this video and the demo, I hope that the concepts explained here are a bit easier to understand, or hopefully you did understand them which is a feat on its own!
If not, don't worry, no matter how much you feel the topic is still far, you are 100% closer than you were before checking this out โ๏ธ
And like always, feel free to check our progress on GitHub from here!