Sunday, May 16, 2010

FakeItEasy Login Service Example Series – Part 2

This is the fourth part in the series of posts where I’m porting Brett Schucherts excelent demo of Mockito in Java to C# and FakeItEasy.

The source for this example series can be found in a Mercurial repository at Google code. Each test implementation and following code update is a separate commit so you can easily update your repository to look at the full code at any given state. Find the repository here.

Part 1 can be found here.

Test 2 – Revoking accounts

User story

After three consecutive failed login attempts to the account, the account shall be revoked.

The test

[Test]
public void Should_revoke_account_after_three_failed_login_attempts()
{
    // Arrange
    var account = A.Fake<IAccount>();
    A.CallTo(() => account.PasswordMatches(A<string>.Ignored)).Returns(false);

    var accountRepository = A.Fake<IAccountRepository>();
    A.CallTo(() => accountRepository.Find(A<string>.Ignored)).Returns(account);

    var service = new LoginService(accountRepository);

    // Act
    service.Login("username", "wrong password");
    service.Login("username", "wrong password");
    service.Login("username", "wrong password");

    // Assert
    A.CallTo(() => account.SetRevoked(true)).MustHaveHappened(Repeated.Once.Exactly);
}

Test description

  1. Create a faked IAccount. Unlike the first test, this fake never matches any password.
  2. Create an IAccountRepository fake and register the IAccount fake with it for any username.
  3. Create the LoginService as before, injecting the IAccountRepository fake.
  4. Attempt to login three times, each time should fail.
  5. Finally, verify that the account was set to revoked after three times. Note that I specify the number of times it should be repeated to once exactly in this case, this is to show the syntax for how to specify repeat. In the first test we just said that the call should have happened any number of times, in this test we say that it must have happened exactly once. -Patrik

Notice that this test does not check that SetLoggedIn is not called. It certainly could and that would make it in a sense more complete. On the other hand, it would also tie the test verification to the underlying implementation and also be testing something that might better be created as its own test (so that’s officially on the punch-list for later implementation).

Things created for compilation

Added the new method SetRevoked(bool isRevoked) to the IAccount-interface.

namespace FakeItEasy.Examples.LoginService
{
    public interface IAccount
    {
        bool PasswordMatches(string password);
        
        void SetLoggedIn(bool isLoggedIn);

        void SetRevoked(bool isRevoked);
    }
}

Failing test

The test fails with the following message:

Assertion failed for the following call:
  'FakeItEasy.Examples.LoginService.IAccount.SetRevoked(True)'
Expected to find it exactly once but found it #0 times among the calls:
  1.  'FakeItEasy.Examples.LoginService.IAccount.SetLoggedIn(True)' repeated 3 times
 

Note that all the calls that has been made to the fake object are listed in the message so that you can easily see what is happening and why the test is failing. -Patrik

Code updated to get test to turn green

Here’s one way to make this test pass (and keep the first test green).

namespace FakeItEasy.Examples.LoginService
{
    using System;

    public class LoginService
    {
        private IAccountRepository accountRepository;
        private int numberOfFailedAttempts;

        public LoginService(IAccountRepository accountRepository)
        {
            this.accountRepository = accountRepository;
        }

        public void Login(string username, string password)
        {
            var account = this.accountRepository.Find(username);
            account.SetLoggedIn(true);

            if (!account.PasswordMatches(password))
            {
                this.numberOfFailedAttempts++;
            }

            if (this.numberOfFailedAttempts == 3)
            {
                account.SetRevoked(true);
            }
        }
    }
}

Refactoring

Sure it is a bit ugly and we can certainly improve on the structure. Before doing that, however, we'll let the production code ripen a bit to get a better sense of its direction. Instead, let's spend some time removing duplication in the unit test code. Rather than make you work through several refactoring steps, here's the final version I came up with:

namespace FakeItEasy.LoginService.Tests
{
    using FakeItEasy.Examples.LoginService;
    using NUnit.Framework;

    [TestFixture]
    public class LoginServiceTests
    {
        private IAccount account;
        private IAccountRepository accountRepository;
        private LoginService service;

        [SetUp]
        public void SetUp()
        {
            this.account = A.Fake<IAccount>();
            this.accountRepository = A.Fake<IAccountRepository>();
            A.CallTo(() => this.accountRepository.Find(A<string>.Ignored)).Returns(this.account);

            this.service = new LoginService(this.accountRepository);
        }

        [Test]
        public void Should_set_account_to_logged_in_when_password_matches()
        {
            // Arrange
            A.CallTo(() => this.account.PasswordMatches(A<string>.Ignored)).Returns(true);

            // Act
            this.service.Login("username", "password");

            // Assert
            A.CallTo(() => this.account.SetLoggedIn(true)).MustHaveHappened();
        }

        [Test]
        public void Should_revoke_account_after_three_failed_login_attempts()
        {
            // Arrange
            A.CallTo(() => this.account.PasswordMatches(A<string>.Ignored)).Returns(false);

            // Act
            this.service.Login("username", "wrong password");
            this.service.Login("username", "wrong password");
            this.service.Login("username", "wrong password");

            // Assert
            A.CallTo(() => this.account.SetRevoked(true)).MustHaveHappened(Repeated.Once.Exactly);
        }
    }
}

This simply extracts common setup to an init() method. However, this cleanup really shortens the individual tests considerably. It also makes their intent clearer.

1 comment: