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.