Bank: Implementing testing and error checking

In the latest iteration of the banking project, I decided to focus on two aspects first:

  • Implementing tests
  • Proper error handling

With the project already well underway, there was quite a lot of refactoring involved and as usual a lot was learned.

Testing

Difficulties around goroutines

The first stop for implementing testing in the current project was looking at the client and the server.

The server runs in a goroutine and is intended to be long living. I’ve been looking at various ways of trying to get the server to quit, but am coming up short. One of the ways of implementing these tests is to use channels, sending errors along the channel and sending a quit signal along a separate channel.

The errors channel was implemented, but the exit channel required a exit message to be sent to the running server. I am explicitly not having an exit command in the CLI to the banking server, so the best option now is to quit the process. In the terminal this is done using CTRL+C. After a few hours of looking into this I decided to move onto the smaller packages. The current non-quitting implementation is below.

func TestRunTLSServer(t *testing.T) {
	errs := make(chan *bankError, 1)
	go func() {
		_, err := runServer("tls")
		if err != nil {
			errs <- err
		}
		close(errs)
		return
	}()
	for err := range errs {
		if err != nil {
			t.Errorf("RunTLSServer does not pass. Looking for %v, got %v", nil, errs)
		}
	}
}

The complexities around implementing testing for the client was the fact that the server had to be running. In the effort to make tests complete, the issues above led to these tests being pushed out too.

I also had to rethink the implementation of the functions in order for them to be truly testable. This was a great exercise in cleaning up the code, and helped with the error implementation later.

Subpackages

After looking at the main, server and client files I decided to move onto the subpackages. I managed to make loads of progress with the accounts package.

The functions were rewritten to fail properly and some refactoring was done to make sure the data was validated. Below is the function to set the account details:

func setAccountDetails(data []string) (accountDetails AccountDetails, err error) {
	if data[4] == "" {
		return AccountDetails{}, errors.New("accounts.setAccountDetails: Family name cannot be empty")
	}
	if data[3] == "" {
		return AccountDetails{}, errors.New("accounts.setAccountDetails: Given name cannot be empty")
	}
	accountDetails.BankNumber = BANK_NUMBER
	accountDetails.AccountHolderName = data[4] + "," + data[3] // Family Name, Given Name
	accountDetails.AccountBalance = OPENING_BALANCE
	accountDetails.Overdraft = OPENING_OVERDRAFT
	accountDetails.AvailableBalance = OPENING_BALANCE + OPENING_OVERDRAFT

	return
}

And here is the associated test suite for the above function:

func TestSetAccountDetails(t *testing.T) {
	tst := []string{"", "", "", "John", "Doe"}
	accountDetails, err := setAccountDetails(tst)

	if err != nil {
		t.Errorf("SetAccountDetails does not pass. ERROR. Looking for %v, got %v", nil, err)
	}

	if reflect.TypeOf(accountDetails).String() != "accounts.AccountDetails" {
		t.Errorf("SetAccountDetails does not pass. TYPE. Looking for %v, got %v", "accounts.AccountDetails", reflect.TypeOf(accountDetails).String())
	}

	if accountDetails.BankNumber != BANK_NUMBER {
		t.Errorf("SetAccountDetails does not pass. DETAILS. Looking for %v, got %v", BANK_NUMBER, accountDetails.BankNumber)
	}

	if accountDetails.Overdraft != OPENING_OVERDRAFT {
		t.Errorf("SetAccountDetails does not pass. DETAILS. Looking for %v, got %v", OPENING_OVERDRAFT, accountDetails.Overdraft)
	}

	if accountDetails.AccountBalance != OPENING_BALANCE {
		t.Errorf("SetAccountDetails does not pass. DETAILS. Looking for %v, got %v", OPENING_BALANCE, accountDetails.AccountBalance)
	}

	if accountDetails.AvailableBalance != (OPENING_BALANCE + OPENING_OVERDRAFT) {
		t.Errorf("SetAccountDetails does not pass. DETAILS. Looking for %v, got %v", (OPENING_BALANCE + OPENING_OVERDRAFT), accountDetails.AvailableBalance)
	}

	if accountDetails.AccountHolderName != "Doe,John" {
		t.Errorf("SetAccountDetails does not pass. DETAILS. Looking for %v, got %v", "Doe,John", accountDetails.AccountHolderName)
	}
}

As you can see from the above test function I pass through known variables, check if the validation is successful and then check every field individually. In this way, the full function is tested.

Errors

As part of the above implementation, and to refactor the code, I decided to implement real error handling. Some of Go’s articles recommend using a custom struct for errors, as seen in this article.

I started off using this approach but found that due to the bank project having a main package and many subpackages, the error struct was difficult to pass through from package to package. I tried copying the struct into all packages, but that would lead to potential problems with struct inconsistency and the types still mismatched. With the struct bankError in the accounts package, the struct was of type accounts.bankError and thus did not match with the bankError struct in the main package.

I decided to go into the Go code and see how errors are thrown in the main code. I found the code for the fmt.Print function and copied the implemention.

if len(data) < 3 {
    return "", errors.New("accounts.ProcessAccount: Not enough fields, minimum 3")
}

All the functions now return their current values as well as an error value. This is carried all the way through to the initiating call. At that call the error will be formatted for the relevant interface: formatted as a string for over the wire on TCP, or returned nicely into an HTTP status for the upcoming HTTP API.

Conclusion

This is the start of a long process. I have begun on the database file for the accounts package, doing a lot of refactoring. I’ll be making my way through the subpackages and writing tests and proper error handling throughout.

All code is available on Github.