TDD in a REPL

REPLs (Read-Eval-Print-Loops) are often billed as a great place to experiment and learn a language or a framework. They provide a very tight feedback loop. However, it can be difficult or time-consuming to extract the knowledge gained from a REPL and include it in your source code. I’ve hit the up arrow many times in Ruby’s pry, trying to find the specific input I wanted to copy. And don’t get me started on dealing with multi-line input. Thankfully, the developers behind F# came up with a clever way of dealing with this problem.

On the surface, F# interactive is a typical REPL for a typed language. It has useful features like autocomplete and pretty printing of values. What sets it apart, though, is the existence of the “send line to F# interactive” feature. Visual Studio, Atom, Vim, and other editors support sending text from a normal code window to an F# interactive session. You can send your current selection of lines with a quick keystroke. This means that, instead of typing in the REPL directly, you can write input for the REPL in a regular file in your editor. F# uses script files (.fsx) for this purpose. By keeping your REPL input in a file, you can leverage syntax coloring and your favorite key bindings while using the REPL. Most importantly, it is really easy to extract code from a REPL session—because it is already in a file.

Recently, I realized that this feature could be put to great use for Test-Driven Development (TDD).

Setting Up an REPL Environment

Add a tdd.fsx script file in your project. This script file will be used as you develop functionality. As a baseline, pull in all the dependency DLLs your project needs and open all the normal namespaces you use for writing code and tests. This will be the first thing you send to the F# interactive session when starting TDD. Let’s assume also that you have some existing code and tests in src/ProjectName/ProjectName.fsproj and tests/ProjectName.Tests/ProjectName.Tests.fsproj respectively.

When adding a new module, the workflow goes like this:

  1. At the bottom of tdd.fsx, create failing unit tests for your new module. You may also want to create the shell of any types or functions required to compile the code.
  2. Add some lines at the bottom of tdd.fsx to invoke your new tests and show output for pass/fail. Generally this just means calling the test function(s).
  3. Send the whole file to F# interactive and see your new tests fail.
  4. In standard TDD fashion, begin filling in your new module’s functions to make tests pass.
  5. Send changes to F# interactive whenever you want to re-run the tests. Because your dependency DLLs are already loaded, evaluating new lines in the REPL is lightning-fast.
  6. While making your tests pass, if you are having trouble figuring out the behavior of the code, don’t quit out and use a full-on debugger. Instead, use the REPL to send the code over line by line. As you are doing so, send expression statements over to the REPL to help you analyze the current state of the code.
  7. When the tests and the code are complete, you can simply move the code part of the script into your src directory and the test part into your test directory.

Example tdd.fsx


// Pull in dependencies
#I "tests/ProjectName.Tests/bin/Debug"

#r "Dependency.One.dll"
#r "Dependency.Two.dll"
// ...
#r "ProjectName.dll"

// open useful namespaces
open Xunit
open FsUnit.Xunit
open System

open ProjectName

// This is the part of the script that will change with each new module

// our new code module
module NewModule =
  let newFunc arg1 arg2 =
    arg1 + arg2

// FsUnit tests for newModule
type ``test module``() =
  
  []
  member x.``test a thing``() =
    NewModule.newFunc 2 3 |> should equal 5

// invoke the test, printing the result and any failure exception.
try
  ``test module``().``test a thing``()
  printfn "PASS"
with
  | _ as ex -> printfn "FAIL %A" ex

Some Thoughts/Caveats

  1. On Mono, NUnit tests will not run in the REPL–they must go through the NUnit test runner. On the other hand, xUnit works well in the REPL but crashed really badly for me on the command line when I had test failures. Thankfully, FsUnit abstracts around the differences between NUnit and xUnit (besides the test annotations), so I can use xUnit in the REPL and NUnit in my project.
  2. If you need to change a type or a function signature in an existing class, you will need to recompile that DLL and then reload it in the script. Reloading the whole script in the interactive session only takes a second or two. Thankfully, you don’t have to close and reopen tdd.fsx to get autocomplete and linting working with your DLL changes. Your editor will detect that the dependency changed.
  3. In my project, I’ve pulled out the test running/output functionality and the DLL requires into a separate file, to keep the tdd.fsx file as lean as possible. I use #load at the top of the file to pull in the other script. Turns out autocomplete from types and functions in the reference script works too. Score!
  4. You may want to set some flags in your script to reduce the noise in the F# interactive output: fsi.ShowDeclarationValues

Better, Stronger, Faster

I’m super-excited about this approach. Because of the tight feedback loop, I get much faster feedback than having to compile the project and run tests. In that sense, it feels lightweight like writing tests in a dynamic language. However, I still get the benefits of a type system.

What I’d love to do next is figure out how to augment the F# interactive session plugin for Atom so I can send a different color text to it, to make the result of my test run even clearer. But even in its current state, I think TDD in a REPL is going to revolutionize the way I develop F#.

Conversation
  • Dax Fohl says:

    Going further, (and referencing a post from last week https://spin.atomicobject.com/2015/11/18/feature-oriented-c-sharp-structure/), I like to maintain the relevant test code into the same file as the source, with #if INTERACTIVE. That way as new requirements are made, you can update your implementation, select your implementation and its test, and run them both in one keystroke.

    It does get a tad monotonous if you’ve got a big file with lots of dependencies because then each time you restart your REPL or change a dependency, you’ve got to go up to the top of the page to reload and re-open them, and then back down to your code under test. I’ve yet to come up with a good solution for that. Clojure’s ability to dynamically redefine dependencies in-place solves this problem, but at the cost of type safety.

  • Comments are closed.