How to write correct test with pytest?

依然范特西╮ 提交于 2019-12-10 16:52:33

问题


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:

  1. Validate Email

  2. Validate Password

  3. Check Password Match

  4. 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

  1. 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?

  1. Getting things from self.view and setting them on self.
  2. Validating those things.
  3. Displaying a message if they're invalid.
  4. Creating an account.
  5. 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

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!