ChangeDetectionAndTesting  

Introduction

A general guideline for a build tool is that you should be able to trust its outputs. This is especially relevant to compiling Scala sources because the Scala compiler is slow and so there is a sizable amount of code in sbt to speed up compilation times.

One simple way sbt speeds up compilation is to run the compiler in the same virtual machine each time. (Note that this is not quite the same as fsc, which reuses the same compiler instance.) This approach results in a speedup by a factor of 2 after the first compile.

A second way to speed up compilation times is to only recompile sources that are out of date. This approach requires some work to do properly. A sign of failing to do it properly is users running clean as part of their normal development cycle. The motivation behind sbt's scripted test framework is to try to find bugs in sbt's partial recompilation so that compilation works as expected and clean is something you don't need to do to get correct outputs.

The basic steps to create a scripted test are:

  1. Create a project that will form the base
  2. Determine the changes to make to that project and any actions to invoke on the project
  3. Create the files comprising the changes
  4. Create a script that consists of statements that apply the changes and invoke actions on the project, declaring whether the actions should succeed or fail.

The first part discusses partial recompilation in sbt and the second part describes scripted tests.

Partial Recompilation

You have a set of Scala source files that you would like to compile. After the initial compilation, you typically only modify some of those files before recompiling. This section describes how sbt determines which files have been changed and which files need to be recompiled.

The steps to partial recompilation are generally:

  1. Determine which sources have been modified.
  2. Determine which sources need to be recompiled.
  3. Remove outdated classes.
  4. Recompile.

There are a few indications that a source has changed:

  1. It no longer exists.
  2. Its last modified time is more recent than the timestamp for the last compile.
  3. Its last modified time is more recent than the last modified time of one of the classes produced from it.
  4. Its SHA or MD5 hash is different from the hash computed when the last compile was done.
  5. One of the classes generated from it does not exist anymore.
  6. Its last modified time is older than that of one of the libraries it depends on

Once a source is detected as out of date, there are three recompilation strategies:

  1. Recompile only the sources directly out of date.
  2. Recompile the sources directly out of date and all sources that (transitively) depend on them.
  3. Recompile all sources.

Finally, when a source is out of date (directly or indirectly), its classes should be deleted.

Through version 0.3.6, sbt used methods 1, 3, 5, and 6 combined to detect changes and recompilation strategy 2. Version 0.3.7 and higher of sbt substitutes change detection method 4 (comparing hashes) for method 3 (comparing last modified times) because of the following problems with method 3:

  1. Changes to a source that produces no classes are not detected (works properly with change detection method 2 or 4).
  2. Changes to a source between starting a compilation and finishing it are not detected (works properly with 2 or 4).
  3. The resolution of the last modified time of a file can be as coarse as 1 second for many filesystems (addressed by 4).

The last issue was especially a problem when implementing the scripted tests. An automated test script could setup, compile, update, and compile again within a second. The second compile wouldn't detect any changes because the last modified times were the same.

One problem with hashing, though, is that it reads in every source file to calculate its hash. As a rough idea, this might take about 1 second per 10 MB of sources on a local filesystem. This mainly affects how long it takes to run compile on a project without any changes. Of course, change detection is configurable, so you can use the last modified method if desired.

Example

As an example, consider the following two definitions:

A.scala

object A {
    val x = B.y
}

B.scala

object B {
    val y = 5
}

Now, consider what happens if you were to delete B.scala but do not update A.scala. When you recompile, you should get an error because B no longer exists for A to reference.

The first problem occurs if you do not recompile A.scala. This would happen if you do not take source dependencies into account and you only recompile directly modified sources (here, A.scala is out of date because it depends on B.scala, but A.scala is not directly modified). A solution for a build system without source dependency tracking would be to recompile all sources. Alternatively, it could omit A.scala from recompilation and consequently require the user to do a full clean and then compile in order to get a proper build.

The second problem is that if you do not delete the classes for B, the compiler will still find the classes for B in the output directory. So, there will not be a compiler error even though you have recompiled A.scala. This shows that it is necessary to track the classes generated from a source file. You want to delete the classes for the sources being recompiled but not delete the classes for the sources not being recompiled.

Testing

The scripted test framework is used to verify that sbt handles cases such as that described above. The steps to create a test are:

  1. Create an initial project in src/sbt-test/<test-group>/<test-name>/
  2. Determine a set of changes to apply to the project (put any new or modified files in a sub-directory called changes/)
  3. Create a script called test in the project directory that modifies the project and runs actions on the project
  4. Run the tests with the scripted action

The directory structure for a test that verifies correctness in the case mentioned in the previous section might look like:

  src/sbt-test/change-detection/remove-test/
     project/
        build.properties
     src/main/scala/
        A.scala
        B.scala
     test

The scripted action runs the test by copying the directory to the temporary directory for your system, loads the project, and runs the test script. For example, on a unix system, the above test might be run from /tmp/sbt_f723ecf/remove-test/, where the f723ecf part is randomly generated.

The next section describes scripts and provides an example of the test script.

Scripts

Syntax

script ::= (comment | statement)+
comment ::= '#' comment-text EOL
statement ::= expected-result (action | command)

action ::= '>' name
command ::= '$' name argument*

expected-result ::= '' | '-'

Example

> compile

$ delete src/main/scala/B.scala

-> compile

Commands

All paths are relative to the project directory.

copy-file fromPath toPath

Copies the file given by fromPath to toPath.
copy fromPath+ toDirectoryPath
Copies the files given by fromPaths to the toDirectoryPath directory. The directory structure relative to the project directory is preserved.
sync fromDirectory toDirectory
Synchronizes fromDirectory and toDirectory so that the contents of toDirectory are identical to that of fromDirectory.
delete path+
Deletes the files given by paths.
touch path+
Creates or updates the last modified time of the given paths.
exists path+
Succeeds if the given paths exist, fails otherwise.
absent path+
Succeeds if the given paths do not exist, fails otherwise.
exec command args*
Executes the given command in a separate process.
pause
Pauses until enter is pressed. It is useful for inspecting the current test state. As noted above, the project directory for tests is copied to the temporary directory and run from there.
sleep time
Calls Thread.sleep(time).
newer source target
Succeeds if the last modification time of source is more recent than that of target or if target does not exist. Fails otherwise.
mkdir path+
Creates directories at the given paths.

Further Examples

See the existing tests for more examples. They are in the src/sbt-test/ directory.

Running Tests

  1. Run the scripted action on the sbt project. You can run selected tasks by using the scripted-only task like the test-only task.
  2. Contribute your tests!