How to Run Python Tests “Continuously” While Coding

Last week I was doing some Test Driven Development training and overheard someone mention another programming language that had a test runner that you could set up to watch your project directory and run your tests when the files changed. I thought that was a neat idea. I also thought I could easily write my own Python script to do the same thing. Here’s a pretty rough version:

import argparse
import os
import subprocess
import time


def get_args():
    parser = argparse.ArgumentParser(
        description="A File Watcher that executes the specified tests"
        )
    parser.add_argument('--tests', action='store', required=True,
                        help='The path to the test file to run')
    parser.add_argument('--project', action='store', required=False,
                        help='The folder where the project files are')
    return parser.parse_args()


def watcher(test_path, project_path=None):
    if not project_path:
        project_path = os.path.dirname(test_path)

    f_dict = {}

    while True:
        files = os.listdir(project_path)
        for f in files:
            full_path = os.path.join(project_path, f)
            mod_time = os.stat(full_path).st_mtime
            if full_path not in f_dict:
                f_dict[full_path] = mod_time
            elif mod_time != f_dict[full_path]:
                # Run the tests
                cmd = ['python', test_path]
                subprocess.call(cmd)
                print('-' * 70)
                f_dict[full_path] = mod_time

        time.sleep(1)


def main():
    args = get_args()
    w = watcher(args.tests, args.project)

if __name__ == '__main__':
    main()

To run this script, you would need to do something like this:

python watcher.py --test ~/path/to/tests.py --project ~/project/path

Now let’s take a moment to talk about this script. The first function uses Python’s argparse module to make the program accept up to two command line arguments: –test and –project. The first is the path to the Python test script while the second is for the folder where the code that is to be tested resides. The next function, watcher, will loop forever and grab all the files out of the folder that was passed in or use the folder that the test file is in. It will grab each file’s modified time and save it to a dictionary. The key is set to the full path of the file and the value is the modification time. Next we check if the modification time has changed. If not, we sleep for a second and check again. If it has changed, then we run the tests.

At this point, you should be able to edit your code and tests in your favorite Python editor and watch your tests run in the terminal.


Using Watchdog

I looked around for other cross-platform methods of watching a directory and came across the watchdog project. It hasn’t been updated since 2015 (at the time of writing), but I tested it out and it seemed to work fine for me. You can install watchdog using pip:

pip install watchdog

Now that we have watchdog installed, let’s create some code that does something similar to the previous example:

import argparse
import os
import subprocess
import time

from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler

def get_args():
    parser = argparse.ArgumentParser(
        description="A File Watcher that executes the specified tests"
        )
    parser.add_argument('--tests', action="store", required=True,
                        help='The path to the test file to run')
    parser.add_argument('--project', action='store', required=False,
                        help='The folder where the project files are')
    return parser.parse_args()


class FW(FileSystemEventHandler):
    def __init__(self, test_file_path):
        self.test_file_path = test_file_path

    def on_any_event(self, event):

        if os.path.exists(self.test_file_path):
            cmd = ['python', self.test_file_path]
            subprocess.call(cmd)
            print('-' * 70)

if __name__ =='__main__':
    args = get_args()
    observer = Observer()
    path = args.tests
    watcher = FW(path)

    if not args.project:
        project_path = os.path.dirname(args.tests)
    else:
        project_path = args.project

    if os.path.exists(path) and os.path.isfile(path):
        observer.schedule(watcher, project_path, recursive=True)
        observer.start()
        try:
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            observer.stop()
        observer.join()
    else:
        print('There is something wrong with your test path')

In this code, we keep our get_args() function and add a class. The class subclass’s watchdog’s FileSystemEventHandler class. We end up passing in our test file path to the class and override the on_any_event() method. This method fires on any time of file system event. When that happens, we run our tests. The last bit is at the end of the code where we create an Observer() object and tell it to watch the specified project path and to call our event handler should anything happen to the files there.


Wrapping Up

At this point, you should be able to start trying out these ideas on your own code. There are also some platform specific methods to watch a folder as well (like PyWin32) but if you run on multiple operating systems like I do, then watchdog or rolling your own might be a better choice.

Related Readings

7 thoughts on “How to Run Python Tests “Continuously” While Coding”

  1. Personally, I’ve found entr (http://entrproject.org/) perfectly adequate for this, I use something along the lines of: `find . -type f -iname ‘*.py’ | entr -c python nosetests` (the -c flag clears the console between consecutive invocations).

  2. I know you are done now, but Nosy is one of the first things I install. It’s not limited to nosetests (by using the ‘test_runner’ config variable: https://pypi.python.org/pypi/nosy

    sample config:

    [nosy]
    # Paths to check for changed files; changes cause nose to be run
    base_path = ./py/
    glob_patterns = *.py *.html *.js *.css
    exclude_patterns = *_flymake.*

    test_runner = python.exe

    # Command line options to pass to nose
    options = tools/nosetests.py –verbosity=3

Comments are closed.