/league
. Along the way we learned about how to deal with JSON, embedding types and routing./league
endpoint should return the players ordered by the number of wins!PlayerStore
abstraction we have used.InMemoryPlayerStore
for now so that the integration tests keep passing as we develop our new store. Once we are confident our new implementation is sufficient to make the integration test pass we will swap it in and then delete InMemoryPlayerStore
.io.Reader
), writing data (io.Writer
) and how we can use the standard library to test these functions without having to use real files.PlayerStore
so we'll write tests for our store calling the methods we need to implement. We'll start with GetLeague
.strings.NewReader
which will return us a Reader
, which is what our FileSystemPlayerStore
will use to read data. In main
we will open a file, which is also a Reader
.FileSystemPlayerStore
in a new fileReader
but not expecting one and it doesn't have GetLeague
defined yet.league.go
and put this inside.getLeagueFromResponse
in server_test.go
io.Reader
is defined.Read
a second time?Reader
has reached the end so there is nothing more to read. We need a way to tell it to go back to the start.FileSystemPlayerStore
to take this interface instead?strings.NewReader
that we used in our test also implements ReadSeeker
so we didn't have to make any other changes.GetPlayerScore
.RecordWin
.Writer
but we already have our ReadSeeker
. Potentially we could have two dependencies but the standard library already has an interface for us ReadWriteSeeker
which lets us do all the things we'll need to do with a file.strings.Reader
does not implement ReadWriteSeeker
so what do we do?*os.File
implements ReadWriteSeeker
. The pro of this is it becomes more of an integration test, we're really reading and writing from the file system so it will give us a very high level of confidence. The cons are we prefer unit tests because they are faster and generally simpler. We will also need to do more work around creating temporary files and then making sure they're removed after the test.strings.Reader
with an os.File
."db"
value we've passed in is a prefix put on a random file name it will create. This is to ensure it won't clash with other files by accident.ReadWriteSeeker
(the file) but also a function. We need to make sure that the file is removed once the test is finished. We don't want to leak details of the files into the test as it's prone to error and uninteresting for the reader. By returning a removeFile
function, we can take care of the details in our helper and all the caller has to do is run defer cleanDatabase()
../file_system_store_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)
league[i].Wins++
rather than player.Wins++
.range
over a slice you are returned the current index of the loop (in our case i
) and a copy of the element at that index. Changing the Wins
value of a copy won't have any effect on the league
slice that we iterate on. For that reason, we need to get the reference to the actual value by doing league[i]
and then changing that value instead.GetPlayerScore
and RecordWin
, we are iterating over []Player
to find a player by name.FileSystemStore
but to me, it feels like this is maybe useful code we can lift into a new type. Working with a "League" so far has always been with []Player
but we can create a new type called League
. This will be easier for other developers to understand and then we can attach useful methods onto that type for us to use.league.go
add the followingLeague
they can easily find a given player.PlayerStore
interface to return League
rather than []Player
. Try to re-run the tests, you'll get a compilation problem because we've changed the interface but it's very easy to fix; just change the return type from []Player
to League
.file_system_store
.League
that can be refactored.Find
returns nil
because it couldn't find the player.Store
in the integration test. This will give us more confidence that the software works and then we can delete the redundant InMemoryPlayerStore
.TestRecordingWinsAndRetrievingThem
replace the old store.InMemoryPlayerStore
. main.go
will now have compilation problems which will motivate us to now use our new store in the "real" code.os.OpenFile
lets you define the permissions for opening the file, in our case O_RDWR
means we want to read and write and os.O_CREATE
means create the file if it doesn't exist.GetLeague()
or GetPlayerScore()
we are reading the entire file and parsing it into JSON. We should not have to do that because FileSystemStore
is entirely responsible for the state of the league; it should only need to read the file when the program starts up and only need to update the file when data changes.FileSystemStore
to be used on the reads instead.f.league
instead.FileSystemPlayerStore
so just fix them by calling our new constructor.RecordWin
, we Seek
back to the start of the file and then write the new data—but what if the new data was smaller than what was there before?Tape
. Create a new file with the following:Write
now, as it encapsulates the Seek
part. This means our FileSystemStore
can just have a reference to a Writer
instead.Tape
Seek
call from RecordWin
. Yes, it doesn't feel much, but at least it means if we do any other kind of writes we can rely on our Write
to behave how we need it to. Plus it will now let us test the potentially problematic code separately and fix it.tape
, and read it all again to see what's in the file. In tape_test.go
:os.File
has a truncate function that will let us effectively empty the file. We should be able to just call this to get what we want.tape
to the following:io.ReadWriteSeeker
but we are sending in *os.File
. You should be able to fix these problems yourself by now but if you get stuck just check the source code.TestTape_Write
test should be passing!RecordWin
we have the line json.NewEncoder(f.database).Encode(f.league)
.Encoder
in our type and initialise it in the constructor:RecordWin
.io.Reader
as that was the easiest path for us to unit test our new PlayerStore
. As we developed the code we moved on to io.ReadWriter
and then io.ReadWriteSeeker
. We then found out there was nothing in the standard library that actually implemented that apart from *os.File
. We could've taken the decision to write our own or use an open source one but it felt pragmatic just to make temporary files for the tests.Truncate
which is also on *os.File
. It would've been an option to create our own interface capturing these requirements.*os.File
so we don't need the polymorphism that interfaces give us.FileSystemStore.go
we have league, _ := NewLeague(f.database)
in our constructor.NewLeague
can return an error if it is unable to parse the league from the io.Reader
that we provide.