问题
I can write some unittests but have no idea how to write test about createAccount() which connect other functions together.
createAccount() contains some steps in order:
Validate Email
Validate Password
Check Password Match
Instantiate new account object
Every step has some test cases. So, my questions are: 1. How to write createAccount() test case ? Should I list all possible combination test cases then test them.
For example:
TestCase0. Email is invalid
TestCase1. App stops after retrying email 3 times
TestCase2. Email is ok, password is not valid
TestCase3. Email is ok, password is valid, 2nd password doesnt match the first one
TestCase4. Email is ok, password is valid, both password match, security is valid
TestCase5. Email is ok, password is vailid, both password match, security is valid, account was create succesfully
- Don't I know how to test because my createAccount() sucks ? If yes, how to refactor it for easier testing ?
This is my code:
class RegisterUI:
def getEmail(self):
return input("Please type an your email:")
def getPassword1(self):
return input("Please type a password:")
def getPassword2(self):
return input("Please confirm your password:")
def getSecKey(self):
return input("Please type your security keyword:")
def printMessage(self,message):
print(message)
class RegisterController:
def __init__(self, view):
self.view = view
def displaymessage(self, message):
self.view.printMessage(message)
def ValidateEmail(self, email):
"""get email from user, check email
"""
self.email = email
email_obj = Email(self.email)
status = email_obj.isValidEmail() and not accounts.isDuplicate(self.email)
if not status:
raise EmailNotOK("Email is duplicate or incorrect format")
else:
return True
def ValidatePassword(self, password):
"""
get password from user, check pass valid
"""
self.password = password
status = Password.isValidPassword(self.password)
if not status:
raise PassNotValid("Pass isn't valid")
else: return True
def CheckPasswordMatch(self, password):
"""
get password 2 from user, check pass match
"""
password_2 = password
status = Password.isMatch(self.password, password_2)
if not status:
raise PassNotMatch("Pass doesn't match")
else: return True
def createAccount(self):
retry = 0
while 1:
try:
email_input = self.view.getEmail()
self.ValidateEmail(email_input) #
break
except EmailNotOK as e:
retry = retry + 1
self.displaymessage(str(e))
if retry > 3:
return
while 1:
try:
password1_input = self.view.getPassword1()
self.ValidatePassword(password1_input)
break
except PassNotValid as e:
self.displaymessage(str(e))
while 1:
try:
password2_input = self.view.getPassword2()
self.CheckPasswordMatch(password2_input)
break
except PassNotMatch as e:
self.displaymessage(str(e))
self.seckey = self.view.getSecKey()
account = Account(Email(self.email), Password(self.password), self.seckey)
message = "Account was create successfully"
self.displaymessage(message)
return account
class Register(Option):
def execute(self):
view = RegisterUI()
controller_one = RegisterController(view)
controller_one.createAccount()
"""========================Code End=============================="""
"""Testing"""
@pytest.fixture(scope="session")
def ctrl():
view = RegisterUI()
return RegisterController(view)
def test_canThrowErrorEmailNotValid(ctrl):
email = 'dddddd'
with pytest.raises(EmailNotOK) as e:
ctrl.ValidateEmail(email)
assert str(e.value) == 'Email is duplicate or incorrect format'
def test_EmailIsValid(ctrl):
email = 'hello@gmail.com'
assert ctrl.ValidateEmail(email) == True
def test_canThrowErrorPassNotValid(ctrl):
password = '123'
with pytest.raises(PassNotValid) as e:
ctrl.ValidatePassword(password)
assert str(e.value) == "Pass isn't valid"
def test_PasswordValid(ctrl):
password = '1234567'
assert ctrl.ValidatePassword(password) == True
def test_canThrowErrorPassNotMatch(ctrl):
password1= '1234567'
ctrl.password = password1
password2 = 'abcdf'
with pytest.raises(PassNotMatch) as e:
ctrl.CheckPasswordMatch(password2)
assert str(e.value) == "Pass doesn't match"
def test_PasswordMatch(ctrl):
password1= '1234567'
ctrl.password = password1
password2 = '1234567'
assert ctrl.CheckPasswordMatch(password2)
回答1:
Note: I don't know Python well, but I do know testing. My Python might not be entirely correct, but the techniques are.
The answer lies in your description of createAccount
. It does too many things. It has wrappers around various validation methods. It displays messages. It creates an account. It needs to be refactored to be testable. Testing and refactoring go hand in hand.
First, perform an Extract Method refactoring on each of the four pieces to turn them into their own methods. I'm only going to do one of the three validation steps, they're all basically the same. Since this is a rote operation we can do it safely. Your IDE might even be able to do the refactor for you.
def tryValidatePassword(self):
while 1:
try:
password1_input = self.view.getPassword1()
self.ValidatePassword(password1_input)
break
except PassNotValid as e:
self.displaymessage(str(e))
def makeAccount(self):
return Account(Email(self.email), Password(self.password), self.seckey)
def createAccount(self):
self.tryValidatePassword()
self.seckey = self.view.getSecKey()
account = self.makeAccount()
message = "Account was create successfully"
self.displaymessage(message)
return account
Just looking at this code reveals a bug: createAccount
doesn't stop if the password is wrong.
Now that we can look at tryValidatePassword
alone, and test it, we see it will enter an infinite loop if the password is invalid. That's no good. I'm not sure what the purpose of the loop is, so let's remove it.
def tryValidatePassword(self):
try:
password1_input = self.view.getPassword1()
self.ValidatePassword(password1_input)
except PassNotValid as e:
self.displaymessage(str(e))
Now it's just a wrapper around ValidatePassword
that prints the exception. This reveals several anti-patterns.
First, ValidatePassword
, and others, are using exception for control flow. It's not exceptional for a validation method to find the thing is invalid. They should return a simple boolean. This simplifies things.
def ValidatePassword(self, password):
"""
get password from user, check pass valid
"""
self.password = password
return Password.isValidPassword(self.password)
Now we see ValidatePassword
is doing two unrelated things: setting the password and validating it. Setting the password should be happening somewhere else.
Also the doc string is incorrect, it doesn't get the password from the user, it just checks it. Delete it. What the method does is obvious from its signature, ValidatePassword validates the password you pass in.
def ValidatePassword(self, password):
return Password.isValidPassword(self.password)
Another anti-pattern is the message displayed by the controller was being determined by the validation method. The controller (or possibly view) should be controlling the message.
def tryValidatePassword(self):
password1_input = self.view.getPassword1()
if !self.ValidatePassword(password1_input):
self.displaymessage("Pass isn't valid")
Finally, instead of passing in the password we're getting it from the object. This is a side-effect. It means you can't tell all the method's inputs just by looking at its parameters. This makes it harder to understand the method.
Sometimes referencing values on the object is necessary and convenient. But this method does one thing: it validates a password. So we should pass that password in.
def tryValidatePassword(self, password):
if !self.ValidatePassword(password):
self.displaymessage("Pass isn't valid")
self.tryValidatePassword(self.view.getPassword1())
There's barely anything left to test! With that we've learned about what's really going on, let's bring it all back together. What is createAccount
really doing?
- Getting things from
self.view
and setting them onself
. - Validating those things.
- Displaying a message if they're invalid.
- Creating an account.
- Displaying a success message.
1 seems unnecessary, why copy the fields from the view to controller? They're never referenced anywhere else. Now that we're passing values into methods this is no longer necessary.
2 already has validation functions. Now that everything is slimmed down we can write thin wrappers to hide the implementation of the validation.
4, creating the account, we've already separated out.
3 and 5, displaying messages, should be separate from doing the work.
Here's what it looks like now.
class RegisterController:
# Thin wrappers to hide the details of the validation implementations.
def ValidatePassword(self, password):
return Password.isValidPassword(password)
# If there needs to be retries, they would happen in here.
def ValidateEmail(self, email_string):
email = Email(email_string)
return email.isValidEmail() and not accounts.isDuplicate(email_string)
def CheckPasswordMatch(self, password1, password2):
return Password.isMatch(password1, password2)
# A thin wrapper to actually make the account from valid input.
def makeAccount(self, email, password, seckey):
return Account(Email(email), Password(password), seckey)
def createAccount(self):
password1 = self.view.getPassword1()
password2 = self.view.getPassword2()
if !self.ValidatePassword(password1):
self.displaymessage("Password is not valid")
return
if !self.CheckPasswordMatch(password1, password2):
self.displaymessage("Passwords don't match")
return
email = self.view.getEmail()
if !self.ValidateEmail(email):
self.displaymessage("Email is duplicate or incorrect format")
return
account = self.makeAccount(email, password, self.view.getSecKey())
self.displaymessage("Account was created successfully")
return
Now the validation wrappers are simple to test, they take inputs and return a boolean. makeAccount
is also simple to test, it takes inputs and returns an Account (or doesn't).
createAccount
is still doing too much. It handles the process of creating an account from a view, but its also displaying messages. We need to separate them.
Now is the time for exceptions! We bring back our validation failure exceptions, but making sure they're all subclasses of CreateAccountFailed
.
# This is just a sketch.
class CreateAccountFailed(Exception):
pass
class PassNotValid(CreateAccountFailed):
pass
class PassNotMatch(CreateAccountFailed):
pass
class EmailNotOK(CreateAccountFailed):
pass
Now createAccount
can throw specific versions of CreateAccountFailed
exceptions if it fails to create an account. This has many benefits. Calling createAccount
is safer. It's more flexible. We can separate out the error handling.
def createAccount(self):
password1 = self.view.getPassword1()
password2 = self.view.getPassword2()
if !self.ValidatePassword(password1):
raise PassNotValid("Password is not valid")
if !self.CheckPasswordMatch(password1, password2):
raise PassNotMatch("Passwords don't match")
email = self.view.getEmail()
if !self.ValidateEmail(email):
raise EmailNotOK("Email is duplicate or incorrect format")
return self.makeAccount(email, password, self.view.getSecKey())
# A thin wrapper to handle the display.
def tryCreateAccount(self):
try
account = self.createAccount()
self.displaymessage("Account was created successfully")
return account
except CreateAccountFailed as e:
self.displaymessage(str(e))
Whew, that was a lot. But now createAccount
can be easily unit tested! Test it will create an Account as expected. Make it throw various exceptions. The validation methods get their own unit tests.
Even tryCreateAccount
can be tested. Mock displaymessage and check that it's called with the right messages in the right situations.
To sum up...
- Don't use exceptions for control flow.
- Do use exceptions for exceptional cases, like failing to create an account.
- Do use exceptions to separate errors from error handling.
- Ruthlessly separate the functionality from the display.
- Ruthlessly shave functions down until they do one thing.
- Use thin wrapper functions to hide implementation.
- Don't put values on an object unless you actually need the object to remember them outside one method.
- Write functions that take input and return a result. No side effects.
来源:https://stackoverflow.com/questions/57351729/how-to-write-correct-test-with-pytest