An introduction to unit testing: Step-by-step walkthrough

by Leon Cullens 22. februari 2012 19:34

Now that you probably have a good grasp on the basics of unit testing, you probably want to see some examples that put all of this knowledge together. This chapter will do just that; I built a little simple ASP.NET MVC 3 application that allows us to withdraw money from an account, deposit money on an account and transfer money to another account. I kept everything very simple, ignoring lots of best-practices to keep the unit testing as easy as possible.

The entity

The first thing I did; I built the entities that we'll need. I'll just show the Account entity here:

public class Account
{
    [HiddenInput(DisplayValue = false)]
    public Guid Id { get; set; }

    [HiddenInput(DisplayValue = false)]
    public string Number { get; set; }

    [HiddenInput(DisplayValue = false)]
    public decimal Balance { get; set; }

    [HiddenInput(DisplayValue = false)]
    public Guid PersonId { get; set; }

    public virtual Person Person { get; set; }
}

The controller

After that I created an AccountController that has some CRUD operations and operations to deposit, withdraw and transfer money. I removed the CRUD operations from this example because the code is long enough as it is:

public class AccountController : Controller
{
    private readonly IAccountRepository _accountRepository;

    public AccountController(IAccountRepository accountRepository)
    {
        _accountRepository = accountRepository;
    }

    public ActionResult Deposit()
    {
        return View();
    }

    [HttpPost]
    public ActionResult Deposit(AmountViewModel viewModel)
    {
        if (ModelState.IsValid)
        {
            Account ownAccount = _accountRepository.List().SingleOrDefault(x => x.Number.Equals(viewModel.OwnAccountNumber));
            ownAccount.Balance += viewModel.Amount;

            _accountRepository.Update(ownAccount);

            return RedirectToAction("Index");
        }

        return View(viewModel);
    }

    public ActionResult Withdraw()
    {
        return View();
    }

    [HttpPost]
    public ActionResult Withdraw(AmountViewModel viewModel)
    {
        if (ModelState.IsValid)
        {
            Account ownAccount = _accountRepository.List().SingleOrDefault(x => x.Number.Equals(viewModel.OwnAccountNumber));

            if (ownAccount.Balance - viewModel.Amount >= 0)
            {
                ownAccount.Balance -= viewModel.Amount;

                _accountRepository.Update(ownAccount);

                return RedirectToAction("Index");
            }

            ModelState.AddModelError("Amount", "There's not enough money on your account.");
            return View(viewModel);
        }

        return View(viewModel);
    }

    public ActionResult Transfer()
    {
        return View();
    }

    [HttpPost]
    public ActionResult Transfer(TransactionViewModel transaction)
    {
        if (ModelState.IsValid)
        {
            Account ownAccount = _accountRepository.List().SingleOrDefault(x => x.Number.Equals(transaction.OwnAccountNumber));
            Account targetAccount = _accountRepository.List().SingleOrDefault(x => x.Number == transaction.TargetAccountNumber);

            if (ownAccount.Balance - transaction.Amount >= 0)
            {
                ownAccount.Balance -= transaction.Amount;
                targetAccount.Balance += transaction.Amount;

                _accountRepository.Update(ownAccount);
                _accountRepository.Update(targetAccount);

                return RedirectToAction("Index");
            }

            ModelState.AddModelError("Amount", "There's not enough money on your account.");
            return View(transaction);
        }

        return View(transaction);
    }
}

Ok, that was a large piece of code, let's break it down:

  1. The first thing we do: we inject an AccountRepository into the controller. ALL database communication will go through this repository. Usually you would talk to the repository via a service layer, but for the sake of simplicity I didn't do that.
  2. There are two methods for each type of method; a method that returns a view (that probably has a nice form that you can fill in) and a method that accepts the data that is posted (hence the HttpPost) from the form, does some magic stuff and saves the changes to the database via the repository class.
  3. Our Deposit() method looks for the correct account based on the account number, increases the balance by the supplied amount and saves this to the database.
  4. Our Withdraw() method looks for the correct account based on the account number, only decreases the balance if the current balance minus the requested amount is larger than or equal to 0, to avoid negative numbers. After that, the changes are persisted to the database.
  5. The Transfer() method does basically the same; it transfers money from account A to account B, if account A has enough money on his/her account.

That was not so bad was it? And yes, I know the code is a mess, you'd usually want to do a lot more validation, and transactions too, but why would I make this example more complicated that needed?

The repository

Now the repository class:

public interface IAccountRepository
{
    IEnumerable<Account> List();

    void Add(Account payment);
    void Update(Account account);
    void Delete(Account payment);
}

public class AccountRepository : IAccountRepository
{
    private readonly EfContext _context = new EfContext();

    public IEnumerable<Account> List()
    {
        return _context.Accounts;
    }

    public void Add(Account payment)
    {
        _context.Accounts.Add(payment);
        _context.SaveChanges();
    }

    public void Update(Account account)
    {
        _context.Accounts.Attach(account);
        _context.Entry(account).State = EntityState.Modified;
        _context.SaveChanges();
    }

    public void Delete(Account payment)
    {
        _context.Accounts.Remove(payment);
        _context.SaveChanges();
    }
}

Nothing very interesting to see here, just a wrapper that wraps the communication with the database, as explained earlier. We use the interface to inject an instance of IAccountRepository into the controller.

We have some more classes, like the EfContext class that just extends Entity Framework's DbContext class, some ViewModels that aren't very interesting and some views, but I won't delve into the views because that doesn't matter for testing. So that's basically it, let's look at the unit tests.

The unit tests

So we have just one test class, the one that will be testing our AccountController:

[TestClass]
public class AccountControllerTests
{
    private Mock<IAccountRepository> _mock;
    private AccountController _controller;

    // The TestInitialize method runs before every test
    [TestInitialize]
    public void Initialize()
    {
        // Arrange
        _mock = new Mock<IAccountRepository>();
        _mock.Setup(x => x.List()).Returns(new[]
        {
            new Account {Balance = 500, Number = "0000001"}, 
            new Account {Balance = 800, Number = "0000002"}
        });

        _controller = new AccountController(_mock.Object);
    }
}

As you can see I've created a class, annotated with MSTest's TestClass attribute, I created an Initialize() method that runs before each individual test method, and I use Moq (a mocking framework) to create a mock for my IAccountRepository interface, that returns 2 accounts when the List() method is called. I also create an AccountController that is supplied with our mock repository. This means that all calls to the repository will be done against our Moq object, instead of a real object that inserts stuff into the database.

Let's look at each individual test (which are also inside the AccountControllerTest class) from now:

[TestMethod]
public void Deposit_NewDeposit_ShouldIncreaseTheAccountBalance()
{
    // Arrange
    AmountViewModel viewModel = new AmountViewModel {Amount = 50, OwnAccountNumber = "0000002"};
    Account account = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000002"));

    // Act
    _controller.Deposit(viewModel);

    // Assert
    Assert.AreEqual(account.Balance, 850);
    _mock.Verify(x => x.Update(It.IsAny<Account>()));
}

We supply the Deposit() method with a ViewModel that says that we want to deposit 50 (dollar? euro?) on the account with number '0000002'. This means that account 0000002 which has a balance of 800 (see the Initialize() method) should have a new balance of 850 (800 + 50). We also verify that the Update method on the repository is called, with any object of type 'Account'. I added this just to show you how verifying mock behaviour works. Some people don't test behaviour, some people do. It's up to you if you want to do this or not.

Then we have two test methods for the Withdraw() method. One where we try to withdraw money when there is enough money on the account, one where we try to withdraw money without having enough money on the account.

[TestMethod]
public void Withdraw_NewWithdrawalWithSufficientBalance_ShouldDecreaseTheAccountBalance()
{
    // Arrange
    AmountViewModel viewModel = new AmountViewModel { Amount = 50, OwnAccountNumber = "0000002" };

    // Act
    _controller.Withdraw(viewModel);

    // Assert
    Account account = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000002"));
    Assert.AreEqual(account.Balance, 750);
    _mock.Verify(x => x.Update(It.IsAny<Account>()));
}

[TestMethod]
public void Withdraw_NewWithdrawalWithoutSufficientBalance_ShouldNotDecreaseTheAccountBalance()
{
    // Arrange
    AmountViewModel viewModel = new AmountViewModel { Amount = 900, OwnAccountNumber = "0000002" };
    Account account = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000002"));

    // Act
    var result = _controller.Withdraw(viewModel) as ViewResult;

    // Assert
    Assert.AreEqual(account.Balance, 800);
    Assert.IsFalse(result.ViewData.ModelState.IsValid);
}

I don't think it's rocket-science that's happening here; we have a method that just withdraws some money successfully and then we validate if the balance of that account is really decreased by 50, and we have a method that tries to withdraw money but fails because there isn't enough money on the account. This means that the balance should still be the same (800) and the ModelState is invalid (that's an ASP.NET MVC / Entity Framework thing, you don't need to bother about them).

It's starting to get boring already, doesn't it? Well, here is some more code, meant to test the Transfer() method:

[TestMethod]
public void Transfer_NewTransferWithSufficientBalance_ShouldIncreaseTheTargetBalanceAndDecreaseTheOwnBalance()
{
    // Arrange
    TransactionViewModel viewModel = new TransactionViewModel
    {
        Amount = 200,
        OwnAccountNumber = "0000001",
        TargetAccountNumber = "0000002"
    };
    Account ownAccount = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000001"));
    Account targetAccount = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000002"));

    // Act
    _controller.Transfer(viewModel);

    // Assert
    Assert.AreEqual(ownAccount.Balance, 300);
    Assert.AreEqual(targetAccount.Balance, 1000);
}

[TestMethod]
public void Transfer_NewTransferWithoutSufficientBalance_ShouldIncreaseTheTargetBalanceAndDecreaseTheOwnBalance()
{
    // Arrange
    TransactionViewModel viewModel = new TransactionViewModel
    {
        Amount = 600,
        OwnAccountNumber = "0000001",
        TargetAccountNumber = "0000002"
    };
    Account ownAccount = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000001"));
    Account targetAccount = _mock.Object.List().SingleOrDefault(x => x.Number.Equals("0000002"));

    // Act
    var result = _controller.Transfer(viewModel) as ViewResult;

    // Assert
    Assert.AreEqual(ownAccount.Balance, 500);
    Assert.AreEqual(targetAccount.Balance, 800);
    Assert.IsFalse(result.ViewData.ModelState.IsValid);
}

We're doing pretty much the same as in the previous test methods, but now we have to validate that two accounts changed their balance (one increased and one decreased) or, in the second test, are still the same.

A wrote a lot more test code, but I found this wall of text quite daunting already, so I had mercy and deleted the rest of the code, I think the examples pretty much cover all the basics.

Next: Conclusion & comments

Tags: , , ,

Testing

Pingbacks and trackbacks (2)+

Comments are closed

about

Name: Leon Cullens
Country: The Netherlands
Job: Software Engineer / Entrepreneur
Studied: Computer Science 
Main skills: Microsoft technology (Azure, ASP.NET MVC, Windows 8, C#, SQL Server, Entity Framework), software architecture (enterprise architecture, design patterns), Marketing, growth hacking, entrepreneurship

advertisements

my apps