Repository: encode/uvicorn

Issue:
Support --ignore or --reload-file option
### Is your feature related to a problem? Please describe.

My application's file structure is as follows:

```
cloud-app git:(dev) ✗ tree -L 1
.
├── Dockerfile
├── README.md
├── frontend
├── gatekeeper
├── iam
├── main.py
├── manage.py
├── requirements.txt
├── templates
└── util.py
```

`frontend` is a React SPA, `gatekeeper` and `iam` belong to a Django application that we mount as an ASGI sub-application alongside our fastapi ASGI app which is defined in `main.py`.

Currently I run uvicorn as follow:

```
uvicorn main:app --reload --host 0.0.0.0 --port 8000 --reload-dir iam --reload-dir gatekeeper
```

I would like to see one of the following (or both!) flags supported:

* `--ignore-dir frontend`: would enable me to ignore changes to frontend source code that are triggering a large number of reloads of my uvicorn app, despite those changes being irrelevant;
* `--reload-file main.py`: would enable me to whitelist _specific files_ to watch.

I believe WatchDog [can be configured](https://pythonhosted.org/watchdog/api.html) to ignore/exclude files and dirs based on pattern matching.

## Refs

https://pythonhosted.org/watchdog/api.html#watchdog.events.RegexMatchingEventHandler.ignore_regexes
https://pythonhosted.org/watchdog/api.html#watchdog.events.PatternMatchingEventHandler.ignore_patterns

Discussion:
Roang-zero1: Sure, I'll start the work on separating out the test refactor.
I'll keep this PR for the reimplementation of the reload function and start a new one for the test refactor.
Roang-zero1: As requested I've separated out the test class refactor to https://github.com/encode/uvicorn/pull/833.

Ready for new round of review. Added some comments to point out where feedback is requesed.
euri10: Will look at it over the weekend and get back to you, thanks !

On Fri, Oct 23, 2020, 7:22 PM Lucas <notifications@github.com> wrote:

> As requested I've separated out the test class refactor to #833
> <https://github.com/encode/uvicorn/pull/833>.
>
> Ready for new round of review. Added some comments to point out where
> feedback is requesed.
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/encode/uvicorn/pull/820#issuecomment-715471940>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAINSPX5VCEQNWOXUTB4SMDSMG3W7ANCNFSM4ST3ARHA>
> .
>

hanneskuettner: Any updates on this PR?
Kludex: Closes #984 
Closes #966 
Kludex: Hi @Roang-zero1 :wave: 

I'm going to review it over the weekend. If you'd like, a merging master would be great! :smile: 
kennylajara: > I'm going to review it over the weekend. 

Bump @Kludex 

Roang-zero1: I've rebased the changes onto the current master.
Unfortunately I don't know why the pipeline fails. I've run the lint and committed the changes, but no change.
Test Suite is successful on my local machine.
euri10: hello @Roang-zero1 , you just need to run `scripts/lint' : that will automatically update the docs with the latest changes you made in the click options

Roang-zero1: @euri10 That's what I did in fd185c4c2501ad3b2156172c28633d887f1cfc7c when I run it now there are no changes to commit.

The error is not present when I run scripts/check locally
euri10: I think that could be the reason the lint script behaves differently locally and in CI, iirc click 8 changed the cli help output format, so we need to be sure you have locally same version as in CI

if that's not the case I'm at a loss
Roang-zero1: Typical issue of using the wrong venv... It's fixed now thanks!
euri10: I made a few comments @Roang-zero1 but this looks good !
Roang-zero1: There is a conceptional question I just realized must be discussed.
There are currently 3 settings:
- reload_dir a list setting where one can explicitly set directories which should be watched.
- reload_include a list of glob patterns to match files which trigger a reload.
- reload_exclude a list of glob patterns to match for files which to not trigger a reload.

As the pull request currently stands any directory in reload_dir and any pattern that exactly matches a directory in reload_includes is matched and added to reload_dir. This means it is currently not possible to glob for directories.
I think I should change this to be possible as it would be against my intuition when using these settings.

Example:
Directory structure
root
├── app_first
├── app_second
├── external
├── javascript
└── stylesheets

Let's say our user chose to include `javascript` and `stylesheets` in reload_dir  but decided to use a glob for the apps `app_*`. Then currently the app directories would *not* be watched. I think this is an problem.

I have four ways to resolve this:
- Separate the functionality of reload_dir and reload_include. Therefore the user has to explicitly set the directories they want to match.
- Resolve the directory glob patterns on startup and add them to the reload_dis if they are directories (Would increase startup costs, but is one time)
- Do not resolve the directory globs and let the `should_watch_dir` of `watchgod` handle the check if it is included
- A hybrid of 2 and 3 where the globs are resolved at the start, but watchgod still verifies the patterns against the triggered directories.

I would vote for the fourth solution. Although it adds complexity it is a good middle ground between speed and functionality.
euri10: I'm also in favor of option #4 @Roang-zero1 and I agree globs should include directories
Roang-zero1: I've added the changes, unfortunately this meant reworking large parts of this PR 🔨. (Sorry @euri10 for the additional work)
There is now only one watcher for the current working directory. This enables watchgod to pick up new files and directories that match the globs that were not present before starting the server.

I think I've added test cases for all new functionality.
I am a bit on the fence as there is some feature duplication between `config.py` and the `watchgodreload.py`, but I believe this to be unavoidable.

I've added a cache to the watchers, this reduces the CPU load, but costs memory. Please indicate if that is ok.

There is now a fixture for testing all the reload related use cases, it is currently scoped for `package`, as I don't have a good enough understanding please verify it the scope is correct.

Roang-zero1: Just saw the failed test. I'll look into what the problem with Windows is tomorrow.
euri10: this is a subtle one:
this test works fine with this logger: `logger.info(f"Will watch for changes in these directories: {list(map(str, self.reload_dirs))}")`
```
        assert (
            caplog.records[-1].message
            == f"Will watch for changes in these directories: {[str(app_dir)]}"
        )
```
you cannot build manually the list `[]`  I think that was what caused the double escaping
euri10: for the failing test, let's deal with that sorting the files in the output for predictable behaviour:

```diff
diff --git a/tests/test_config.py b/tests/test_config.py
index a999b33..73f2e5f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -225,8 +225,10 @@ def test_reload_includes_exclude_dir_patterns_are_matched(
                 "Will watch for changes in these directories: "
                 in caplog.records[-1].message
             )
-            assert str(first_app_dir) in caplog.records[-1].message
-            assert str(second_app_dir) in caplog.records[-1].message
+            assert (
+                    caplog.records[-1].message
+                    == f"Will watch for changes in these directories: {sorted([str(first_app_dir), str(second_app_dir)])}"
+            )
             assert frozenset(config.reload_dirs) == frozenset(
                 [first_app_dir, second_app_dir]
             )

             )
             )
diff --git a/uvicorn/config.py b/uvicorn/config.py
index 4fae430..13f5898 100644
--- a/uvicorn/config.py
+++ b/uvicorn/config.py
@@ -331,7 +331,7 @@ class Config:

             logger.info(
                 "Will watch for changes in these directories: %s",
-                list(map(str, self.reload_dirs)),
+                sorted(list(map(str, self.reload_dirs))),
             )

         if env_file is not None:
```
euri10: thanks a lot @Roang-zero1 for this, this is a very nice enhancement !

Patch:
diff --git a/docs/deployment.md b/docs/deployment.md
index 819a932..ad8c099 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -37,6 +37,13 @@ Options:
   --reload                        Enable auto-reload.
   --reload-dir PATH               Set reload directories explicitly, instead
                                   of using the current working directory.
+  --reload-include TEXT           Set glob patterns to include while watching
+                                  for files. Includes '*.py' by default, which
+                                  can be overridden in reload-excludes.
+  --reload-exclude TEXT           Set glob patterns to exclude while watching
+                                  for files. Includes '.*, .py[cod], .sw.*,
+                                  ~*' by default, which can be overridden in
+                                  reload-excludes.
   --reload-delay FLOAT            Delay between previous and next check if
                                   application needs to be. Defaults to 0.25s.
                                   [default: 0.25]
diff --git a/docs/index.md b/docs/index.md
index 1af05b9..1b34808 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -110,6 +110,13 @@ Options:
   --reload                        Enable auto-reload.
   --reload-dir PATH               Set reload directories explicitly, instead
                                   of using the current working directory.
+  --reload-include TEXT           Set glob patterns to include while watching
+                                  for files. Includes '*.py' by default, which
+                                  can be overridden in reload-excludes.
+  --reload-exclude TEXT           Set glob patterns to exclude while watching
+                                  for files. Includes '.*, .py[cod], .sw.*,
+                                  ~*' by default, which can be overridden in
+                                  reload-excludes.
   --reload-delay FLOAT            Delay between previous and next check if
                                   application needs to be. Defaults to 0.25s.
                                   [default: 0.25]
diff --git a/docs/settings.md b/docs/settings.md
index 42ded24..c4d4e50 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -22,7 +22,14 @@ you should put `uvicorn.run` into `if __name__ == '__main__'` clause in the main
 ## Development
 
 * `--reload` - Enable auto-reload.
-* `--reload-dir <path>` - Specify which directories to watch for python file changes. May be used multiple times. If unused, then by default all directories in current directory will be watched. If you are running programmatically use `reload_dirs=[]` and pass a list of strings.
+* `--reload-dir <path>` - Specify which directories to watch for python file changes. May be used multiple times. If unused, then by default the whole current directory will be watched. If you are running programmatically use `reload_dirs=[]` and pass a list of strings.
+* `--reload-include <glob-pattern>` - Specify a glob pattern to match files or directories which will be watched. May be used multiple times. By default the following patterns are included: `*.py`. These can be overwritten by explicitly excluding them.
+* `--reload-exclude <glob-pattern>` - Specify a glob pattern to match files or directories which will excluded from watching. May be used multiple times. By default the following patterns are excluded: `.*, .py[cod], .sw.*, ~*`. These can be overwritten by explicitly including them.
+
+By default Uvicorn uses simple changes detection strategy that compares python files modification times few times a second. If this approach doesn't work for your project (eg. because of its complexity), or you need watching of non python files you can install [watchgod](https://pypi.org/project/watchgod/) or install uvicorn with `uvicorn[standard]`, which will include watchgod.
+
+```
+```
 
 ## Production
 
diff --git a/uvicorn/config.py b/uvicorn/config.py
index d14fc70..13f5898 100644
--- a/uvicorn/config.py
+++ b/uvicorn/config.py
@@ -7,6 +7,7 @@ import os
 import socket
 import ssl
 import sys
+from pathlib import Path
 from typing import Awaitable, Callable, Dict, List, Optional, Tuple, Type, Union
 
 from uvicorn.logging import TRACE_LOG_LEVEL
@@ -132,6 +133,56 @@ def create_ssl_context(
     return ctx
 
 
+def is_dir(path: Path) -> bool:
+    try:
+        if not path.is_absolute():
+            path = path.resolve()
+        return path.is_dir()
+    except OSError:
+        return False
+
+
+def resolve_reload_patterns(
+    patterns_list: List[str], directories_list: List[str]
+) -> Tuple[List[str], List[Path]]:
+
+    directories: List[Path] = list(set(map(Path, directories_list.copy())))
+    patterns: List[str] = patterns_list.copy()
+
+    current_working_directory = Path.cwd()
+    for pattern in patterns_list:
+        # Special case for the .* pattern, otherwise this would only match
+        # hidden directories which is probably undesired
+        if pattern == ".*":
+            continue
+        patterns.append(pattern)
+        if is_dir(Path(pattern)):
+            directories.append(Path(pattern))
+        else:
+            for match in current_working_directory.glob(pattern):
+                if is_dir(match):
+                    directories.append(match)
+
+    directories = list(set(directories))
+    directories = list(map(Path, directories))
+    directories = list(map(lambda x: x.resolve(), directories))
+    directories = list(
+        set([reload_path for reload_path in directories if is_dir(reload_path)])
+    )
+
+    children = []
+    for j in range(len(directories)):
+        for k in range(j + 1, len(directories)):
+            if directories[j] in directories[k].parents:
+                children.append(directories[k])
+            elif directories[k] in directories[j].parents:
+                children.append(directories[j])
+
+    directories = list(set(directories).difference(set(children)))
+
+    return (list(set(patterns)), directories)
+
+
 class Config:
     def __init__(
         self,
@@ -157,6 +208,8 @@ class Config:
         reload: bool = False,
         reload_dirs: Optional[Union[List[str], str]] = None,
         reload_delay: Optional[float] = None,
+        reload_includes: Optional[Union[List[str], str]] = None,
+        reload_excludes: Optional[Union[List[str], str]] = None,
         workers: Optional[int] = None,
         proxy_headers: bool = True,
         server_header: bool = True,
@@ -224,13 +277,62 @@ class Config:
         self.loaded = False
         self.configure_logging()
 
-        if reload_dirs is None:
-            self.reload_dirs = [os.getcwd()]
-        else:
-            if isinstance(reload_dirs, str):
-                self.reload_dirs = [reload_dirs]
-            else:
-                self.reload_dirs = reload_dirs
+        self.reload_dirs: List[Path] = []
+        self.reload_dirs_excludes: List[Path] = []
+        self.reload_includes: List[str] = []
+        self.reload_excludes: List[str] = []
+
+        if (
+            reload_dirs or reload_includes or reload_excludes
+        ) and not self.should_reload:
+            logger.warning(
+                "Current configuration will not reload as not all conditions are met,"
+                "please refer to documentation."
+            )
+
+        if self.should_reload:
+            reload_dirs = list(set(reload_dirs)) if reload_dirs else []
+            reload_includes = list(set(reload_includes)) if reload_includes else []
+            reload_excludes = list(set(reload_excludes)) if reload_excludes else []
+
+            self.reload_includes, self.reload_dirs = resolve_reload_patterns(
+                reload_includes, reload_dirs
+            )
+
+            self.reload_excludes, self.reload_dirs_excludes = resolve_reload_patterns(
+                reload_excludes, []
+            )
+
+            reload_dirs_tmp = self.reload_dirs.copy()
+
+            for directory in self.reload_dirs_excludes:
+                for reload_directory in reload_dirs_tmp:
+                    if (
+                        directory == reload_directory
+                        or directory in reload_directory.parents
+                    ):
+                        try:
+                            self.reload_dirs.remove(reload_directory)
+                        except ValueError:
+                            pass
+
+            for pattern in self.reload_excludes:
+                if pattern in self.reload_includes:
+                    self.reload_includes.remove(pattern)
+
+            if not self.reload_dirs:
+                if reload_dirs:
+                    logger.warning(
+                        "Provided reload directories %s did not contain valid "
+                        + "directories, watching current working directory.",
+                        reload_dirs,
+                    )
+                self.reload_dirs = [Path(os.getcwd())]
+
+            logger.info(
+                "Will watch for changes in these directories: %s",
+                sorted(list(map(str, self.reload_dirs))),
+            )
 
         if env_file is not None:
             from dotenv import load_dotenv
diff --git a/uvicorn/main.py b/uvicorn/main.py
index 42d3415..b3514b7 100644
--- a/uvicorn/main.py
+++ b/uvicorn/main.py
@@ -80,6 +80,20 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No
     " directory.",
     type=click.Path(exists=True),
 )
+@click.option(
+    "--reload-include",
+    "reload_includes",
+    multiple=True,
+    help="Set glob patterns to include while watching for files. Includes '*.py' "
+    "by default, which can be overridden in reload-excludes.",
+)
+@click.option(
+    "--reload-exclude",
+    "reload_excludes",
+    multiple=True,
+    help="Set glob patterns to exclude while watching for files. Includes "
+    "'.*, .py[cod], .sw.*, ~*' by default, which can be overridden in reload-excludes.",
+)
 @click.option(
     "--reload-delay",
     type=float,
@@ -333,6 +347,8 @@ def main(
     debug: bool,
     reload: bool,
     reload_dirs: typing.List[str],
+    reload_includes: typing.List[str],
+    reload_excludes: typing.List[str],
     reload_delay: float,
     workers: int,
     env_file: str,
@@ -382,6 +398,8 @@ def main(
         "debug": debug,
         "reload": reload,
         "reload_dirs": reload_dirs if reload_dirs else None,
+        "reload_includes": reload_includes if reload_includes else None,
+        "reload_excludes": reload_excludes if reload_excludes else None,
         "reload_delay": reload_delay,
         "workers": workers,
         "proxy_headers": proxy_headers,
diff --git a/uvicorn/supervisors/basereload.py b/uvicorn/supervisors/basereload.py
index 82f719c..ff24626 100644
--- a/uvicorn/supervisors/basereload.py
+++ b/uvicorn/supervisors/basereload.py
@@ -4,7 +4,7 @@ import signal
 import threading
 from socket import socket
 from types import FrameType
-from typing import Callable, Dict, List, Optional
+from typing import Callable, List, Optional
 
 import click
 
@@ -64,7 +64,6 @@ class BaseReload:
         self.process.start()
 
     def restart(self) -> None:
-        self.mtimes: Dict[str, float] = {}
 
         self.process.terminate()
         self.process.join()
diff --git a/uvicorn/supervisors/statreload.py b/uvicorn/supervisors/statreload.py
index 94acbd5..2c9857e 100644
--- a/uvicorn/supervisors/statreload.py
+++ b/uvicorn/supervisors/statreload.py
@@ -1,8 +1,7 @@
 import logging
-import os
 from pathlib import Path
 from socket import socket
-from typing import Callable, Iterator, List, Optional
+from typing import Callable, Dict, Iterator, List, Optional
 
 from uvicorn.config import Config
 from uvicorn.supervisors.basereload import BaseReload
@@ -19,31 +18,35 @@ class StatReload(BaseReload):
     ) -> None:
         super().__init__(config, target, sockets)
         self.reloader_name = "statreload"
-        self.mtimes = {}
+        self.mtimes: Dict[Path, float] = {}
 
     def should_restart(self) -> bool:
-        for filename in self.iter_py_files():
+        for file in self.iter_py_files():
             try:
-                mtime = os.path.getmtime(filename)
+                mtime = file.stat().st_mtime
             except OSError:  # pragma: nocover
                 continue
 
-            old_time = self.mtimes.get(filename)
+            old_time = self.mtimes.get(file)
             if old_time is None:
-                self.mtimes[filename] = mtime
+                self.mtimes[file] = mtime
                 continue
             elif mtime > old_time:
-                display_path = os.path.normpath(filename)
-                if Path.cwd() in Path(filename).parents:
-                    display_path = os.path.normpath(os.path.relpath(filename))
+                display_path = str(file)
+                try:
+                    display_path = str(file.relative_to(Path.cwd()))
+                except ValueError:
+                    pass
                 message = "StatReload detected file change in '%s'. Reloading..."
                 logger.warning(message, display_path)
                 return True
         return False
 
-    def iter_py_files(self) -> Iterator[str]:
+    def restart(self) -> None:
+        self.mtimes = {}
+        return super().restart()
+
+    def iter_py_files(self) -> Iterator[Path]:
         for reload_dir in self.config.reload_dirs:
-            for subdir, dirs, files in os.walk(reload_dir):
-                for file in files:
-                    if file.endswith(".py"):
-                        yield subdir + os.sep + file
+            for path in list(reload_dir.rglob("*.py")):
+                yield path.resolve()
diff --git a/uvicorn/supervisors/watchgodreload.py b/uvicorn/supervisors/watchgodreload.py
index 11c5631..92ef1f5 100644
--- a/uvicorn/supervisors/watchgodreload.py
+++ b/uvicorn/supervisors/watchgodreload.py
@@ -1,9 +1,7 @@
 import logging
-import os
-import re
 from pathlib import Path
 from socket import socket
-from typing import TYPE_CHECKING, Callable, List, Optional
+from typing import TYPE_CHECKING, Callable, Dict, List, Optional
 
 from watchgod import DefaultWatcher
 
@@ -12,23 +10,116 @@ from uvicorn.supervisors.basereload import BaseReload
 
 logger = logging.getLogger("uvicorn.error")
 
-if TYPE_CHECKING:
+if TYPE_CHECKING:  # pragma: no cover
+    import os
+
     DirEntry = os.DirEntry[str]
 
 
 class CustomWatcher(DefaultWatcher):
-    ignore_dotted_file_regex = r"^\/?(?:\w+\/)*(\.\w+)"
-    ignored: List[str] = []
+    def __init__(self, root_path: Path, config: Config):
+        default_includes = ["*.py"]
+        self.includes = [
+            default
+            for default in default_includes
+            if default not in config.reload_excludes
+        ]
+        self.includes.extend(config.reload_includes)
+        self.includes = list(set(self.includes))
+
+        default_excludes = [".*", ".py[cod]", ".sw.*", "~*"]
+        self.excludes = [
+            default
+            for default in default_excludes
+            if default not in config.reload_includes
+        ]
+        self.excludes.extend(config.reload_excludes)
+        self.excludes = list(set(self.excludes))
 
-    def __init__(self, root_path: str) -> None:
-        for t in self.ignored_file_regexes:
-            self.ignored.append(t)
-        self.ignored.append(self.ignore_dotted_file_regex)
-        self._ignored = tuple(re.compile(r) for r in self.ignored)
-        super().__init__(root_path)
+        self.watched_dirs: Dict[str, bool] = {}
+        self.watched_files: Dict[str, bool] = {}
+        self.dirs_includes = set(config.reload_dirs)
+        self.dirs_excludes = set(config.reload_dirs_excludes)
+        self.resolved_root = root_path
+        super().__init__(str(root_path))
 
     def should_watch_file(self, entry: "DirEntry") -> bool:
-        return not any(r.search(entry.name) for r in self._ignored)
+        cached_result = self.watched_files.get(entry.path)
+        if cached_result is not None:
+            return cached_result
+
+        entry_path = Path(entry)
+
+        # cwd is not verified through should_watch_dir, so we need to verify here
+        if entry_path.parent == Path.cwd() and not Path.cwd() in self.dirs_includes:
+            self.watched_files[entry.path] = False
+            return False
+        for include_pattern in self.includes:
+            if entry_path.match(include_pattern):
+                for exclude_pattern in self.excludes:
+                    if entry_path.match(exclude_pattern):
+                        self.watched_files[entry.path] = False
+                        return False
+                self.watched_files[entry.path] = True
+                return True
+        self.watched_files[entry.path] = False
+        return False
+
+    def should_watch_dir(self, entry: "DirEntry") -> bool:
+        cached_result = self.watched_dirs.get(entry.path)
+        if cached_result is not None:
+            return cached_result
+
+        entry_path = Path(entry)
+
+        if entry_path in self.dirs_excludes:
+            self.watched_dirs[entry.path] = False
+            return False
+
+        for exclude_pattern in self.excludes:
+            if entry_path.match(exclude_pattern):
+                is_watched = False
+                if entry_path in self.dirs_includes:
+                    is_watched = True
+
+                for directory in self.dirs_includes:
+                    if directory in entry_path.parents:
+                        is_watched = True
+
+                if is_watched:
+                    logger.debug(
+                        "WatchGodReload detected a new excluded dir '%s' in '%s'; "
+                        "Adding to exclude list.",
+                        entry_path.relative_to(self.resolved_root),
+                        str(self.resolved_root),
+                    )
+                self.watched_dirs[entry.path] = False
+                self.dirs_excludes.add(entry_path)
+                return False
+
+        if entry_path in self.dirs_includes:
+            self.watched_dirs[entry.path] = True
+            return True
+
+        for directory in self.dirs_includes:
+            if directory in entry_path.parents:
+                self.watched_dirs[entry.path] = True
+                return True
+
+        for include_pattern in self.includes:
+            if entry_path.match(include_pattern):
+                logger.info(
+                    "WatchGodReload detected a new reload dir '%s' in '%s'; "
+                    "Adding to watch list.",
+                    str(entry_path.relative_to(self.resolved_root)),
+                    str(self.resolved_root),
+                )
+                self.dirs_includes.add(entry_path)
+                self.watched_dirs[entry.path] = True
+                return True
+
+        self.watched_dirs[entry.path] = False
+        return False
 
 
 class WatchGodReload(BaseReload):
@@ -41,26 +132,14 @@ class WatchGodReload(BaseReload):
         super().__init__(config, target, sockets)
         self.reloader_name = "watchgod"
         self.watchers = []
-        watch_dirs = {
-            Path(watch_dir).resolve()
-            for watch_dir in self.config.reload_dirs
-            if Path(watch_dir).is_dir()
-        }
-        watch_dirs_set = set(watch_dirs)
-
-        # remove directories that already have a parent watched, so that we don't have
-        # duplicated change events
-        for watch_dir in watch_dirs:
-            for compare_dir in watch_dirs:
-                if compare_dir is watch_dir:
-                    continue
-
-                if compare_dir in watch_dir.parents:
-                    watch_dirs_set.remove(watch_dir)
-
-        self.watch_dir_set = watch_dirs_set
-        for w in watch_dirs_set:
-            self.watchers.append(CustomWatcher(str(w)))
+        reload_dirs = []
+        for directory in config.reload_dirs:
+            if Path.cwd() not in directory.parents:
+                reload_dirs.append(directory)
+        if Path.cwd() not in reload_dirs:
+            reload_dirs.append(Path.cwd())
+        for w in reload_dirs:
+            self.watchers.append(CustomWatcher(w.resolve(), self.config))
 
     def should_restart(self) -> bool:
         for watcher in self.watchers:


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index dc99d8b..3590ae3 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -71,3 +71,60 @@ def tls_ca_ssl_context(tls_certificate_authority: trustme.CA) -> ssl.SSLContext:
     ssl_ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
     tls_certificate_authority.configure_trust(ssl_ctx)
     return ssl_ctx
+
+
+@pytest.fixture(scope="package")
+def reload_directory_structure(tmp_path_factory: pytest.TempPathFactory):
+    """
+    This fixture creates a directory structure to enable reload parameter tests
+
+    The fixture has the following structure:
+    root
+    ├── [app, app_first, app_second, app_third]
+    │   ├── css
+    │   │   └── main.css
+    │   ├── js
+    │   │   └── main.js
+    │   ├── src
+    │   │   └── main.py
+    │   └── sub
+    │       └── sub.py
+    ├── ext
+    │   └── ext.jpg
+    └── main.py
+    """
+    root = tmp_path_factory.mktemp("reload_directory")
+    apps = ["app", "app_first", "app_second", "app_third"]
+
+    root_file = root / "main.py"
+    root_file.touch()
+
+    dotted_file = root / ".dotted"
+    dotted_file.touch()
+
+    dotted_dir = root / ".dotted_dir"
+    dotted_dir.mkdir()
+    dotted_dir_file = dotted_dir / "file.txt"
+    dotted_dir_file.touch()
+
+    for app in apps:
+        app_path = root / app
+        app_path.mkdir()
+        dir_files = [
+            ("src", ["main.py"]),
+            ("js", ["main.js"]),
+            ("css", ["main.css"]),
+            ("sub", ["sub.py"]),
+        ]
+        for directory, files in dir_files:
+            directory_path = app_path / directory
+            directory_path.mkdir()
+            for file in files:
+                file_path = directory_path / file
+                file_path.touch()
+    ext_dir = root / "ext"
+    ext_dir.mkdir()
+    ext_file = ext_dir / "ext.jpg"
+    ext_file.touch()
+
+    yield root
diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py
index 6e9bd19..3d16a9b 100644
--- a/tests/supervisors/test_reload.py
+++ b/tests/supervisors/test_reload.py
@@ -1,23 +1,25 @@
 import signal
+from logging import DEBUG, INFO, WARNING
 from pathlib import Path
 from time import sleep
 
 import pytest
 
+from tests.utils import as_cwd
 from uvicorn.config import Config
 from uvicorn.supervisors.basereload import BaseReload
 from uvicorn.supervisors.statreload import StatReload
 from uvicorn.supervisors.watchgodreload import WatchGodReload
 
 
-@pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
 class TestBaseReload:
-    tmp_path: Path
-
     @pytest.fixture(autouse=True)
-    def setup(self, tmpdir, reloader_class):
-        self.tmpdir = tmpdir
-        self.tmp_path = Path(tmpdir)
+    def setup(
+        self,
+        reload_directory_structure: Path,
+        reloader_class: BaseReload,
+    ):
+        self.reload_path = reload_directory_structure
         self.reloader_class = reloader_class
 
     def run(self, sockets):
@@ -25,6 +27,7 @@ class TestBaseReload:
 
     def _setup_reloader(self, config: Config) -> BaseReload:
         reloader = self.reloader_class(config, target=self.run, sockets=[])
+        assert config.should_reload
         reloader.signal_handler(sig=signal.SIGINT, frame=None)
         reloader.startup()
         return reloader
@@ -36,63 +39,342 @@ class TestBaseReload:
         file.touch()
         return reloader.should_restart()
 
-    def test_reloader_should_initialize(self):
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_reloader_should_initialize(self) -> None:
         """
         A basic sanity check.
 
         Simply run the reloader against a no-op server, and signal for it to
         quit immediately.
         """
-        config = Config(app=None, reload=True)
-        reloader = self._setup_reloader(config)
-        reloader.shutdown()
+        with as_cwd(self.reload_path):
+            config = Config(app="tests.test_config:asgi_app", reload=True)
+            reloader = self._setup_reloader(config)
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_reload_when_python_file_is_changed(self) -> None:
+        file = self.reload_path / "main.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(app="tests.test_config:asgi_app", reload=True)
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_should_reload_when_python_file_in_subdir_is_changed(self) -> None:
+        file = self.reload_path / "app" / "sub" / "sub.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(app="tests.test_config:asgi_app", reload=True)
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed(
+        self,
+    ) -> None:
+        sub_dir = self.reload_path / "app" / "sub"
+        sub_file = sub_dir / "sub.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_excludes=[str(sub_dir)],
+            )
+            reloader = self._setup_reloader(config)
+
+            assert not self._reload_tester(reloader, sub_file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize(
+        "reloader_class, result", [(StatReload, False), (WatchGodReload, True)]
+    )
+    def test_reload_when_pattern_matched_file_is_changed(self, result: bool) -> None:
+        file = self.reload_path / "app" / "js" / "main.js"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app", reload=True, reload_includes=["*.js"]
+            )
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, file) == result
 
-    def test_should_reload_when_python_file_is_changed(self):
-        file = "example.py"
-        update_file = self.tmp_path.joinpath(file)
-        update_file.touch()
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_not_reload_when_exclude_pattern_match_file_is_changed(self) -> None:
+        python_file = self.reload_path / "app" / "src" / "main.py"
+        css_file = self.reload_path / "app" / "css" / "main.css"
+        js_file = self.reload_path / "app" / "js" / "main.js"
 
-        with self.tmpdir.as_cwd():
-            config = Config(app=None, reload=True)
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_includes=["*"],
+                reload_excludes=["*.js"],
+            )
             reloader = self._setup_reloader(config)
 
-            assert self._reload_tester(reloader, update_file)
+            assert self._reload_tester(reloader, python_file)
+            assert self._reload_tester(reloader, css_file)
+            assert not self._reload_tester(reloader, js_file)
 
             reloader.shutdown()
 
-    def test_should_not_reload_when_dot_file_is_changed(self):
-        file = ".dotted"
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_should_not_reload_when_dot_file_is_changed(self) -> None:
+        file = self.reload_path / ".dotted"
 
-        update_file = self.tmp_path.joinpath(file)
-        update_file.touch()
+        with as_cwd(self.reload_path):
+            config = Config(app="tests.test_config:asgi_app", reload=True)
+            reloader = self._setup_reloader(config)
+
+            assert not self._reload_tester(reloader, file)
+
+            reloader.shutdown()
 
-        with self.tmpdir.as_cwd():
-            config = Config(app=None, reload=True)
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_should_reload_when_directories_have_same_prefix(self) -> None:
+        app_dir = self.reload_path / "app"
+        app_file = app_dir / "src" / "main.py"
+        app_first_dir = self.reload_path / "app_first"
+        app_first_file = app_first_dir / "src" / "main.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_dirs=[str(app_dir), str(app_first_dir)],
+            )
             reloader = self._setup_reloader(config)
 
-            assert not self._reload_tester(reloader, update_file)
+            assert self._reload_tester(reloader, app_file)
+            assert self._reload_tester(reloader, app_first_file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_should_not_reload_when_only_subdirectory_is_watched(self) -> None:
+        app_dir = self.reload_path / "app"
+        app_dir_file = self.reload_path / "app" / "src" / "main.py"
+        root_file = self.reload_path / "main.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_dirs=[str(app_dir)],
+            )
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, app_dir_file)
+            assert not self._reload_tester(reloader, root_file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload])
+    def test_should_parse_dir_from_includes(self) -> None:
+        app_dir = self.reload_path / "app"
+        app_file = app_dir / "src" / "main.py"
+        app_first_dir = self.reload_path / "app_first"
+        app_first_file = app_first_dir / "src" / "main.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_includes=[str(app_dir)],
+            )
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, app_file)
+            assert not self._reload_tester(reloader, app_first_file)
 
             reloader.shutdown()
 
-    def test_should_reload_when_directories_have_same_prefix(self):
-        file = "example.py"
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_override_defaults(self) -> None:
+        dotted_file = self.reload_path / ".dotted"
+        dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt"
+        python_file = self.reload_path / "main.py"
 
-        app_dir = self.tmp_path.joinpath("app")
-        app_ext_dir = self.tmp_path.joinpath("app_extension")
-        app_file = app_dir.joinpath(file)
-        app_ext_file = app_ext_dir.joinpath(file)
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                # We need to add *.txt otherwise no regular files will match
+                reload_includes=[".*", "*.txt"],
+                reload_excludes=["*.py"],
+            )
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, dotted_file)
+            assert self._reload_tester(reloader, dotted_dir_file)
+            assert not self._reload_tester(reloader, python_file)
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_start_one_watcher_for_dirs_inside_cwd(
+        self, caplog: pytest.LogCaptureFixture
+    ) -> None:
+        app_file = self.reload_path / "app" / "src" / "main.py"
+        app_first_file = self.reload_path / "app_first" / "src" / "main.py"
+
+        with as_cwd(self.reload_path):
+            config = Config(
+                app="tests.test_config:asgi_app", reload=True, reload_includes=["app*"]
+            )
+            reloader = self._setup_reloader(config)
+            assert len(reloader.watchers) == 1
+
+            assert self.reload_path == reloader.watchers[0].resolved_root
+
+            assert self._reload_tester(reloader, app_file)
+            assert (
+                caplog.records[-1].message
+                == f"WatchGodReload detected file change in '{[str(app_file)]}'."
+                " Reloading..."
+            )
+            assert caplog.records[-1].levelno == WARNING
+            assert self._reload_tester(reloader, app_first_file)
+            assert "WatchGodReload detected file change" in caplog.records[-1].message
+            assert (
+                caplog.records[-1].message == "WatchGodReload detected file change in "
+                f"'{[str(app_first_file)]}'. Reloading..."
+            )
+            assert caplog.records[-1].levelno == WARNING
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_start_separate_watchers_for_dirs_outside_cwd(
+        self, caplog: pytest.LogCaptureFixture
+    ) -> None:
+        app_dir = self.reload_path / "app"
+        app_file = self.reload_path / "app" / "src" / "main.py"
+        app_first_dir = self.reload_path / "app_first"
+        app_first_file = app_first_dir / "src" / "main.py"
+
+        with as_cwd(app_dir):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_dirs=[str(app_dir), str(app_first_dir)],
+            )
+            reloader = self._setup_reloader(config)
+            assert len(reloader.watchers) == 2
+
+            assert frozenset([app_dir, app_first_dir]) == frozenset(
+                [x.resolved_root for x in reloader.watchers]
+            )
+
+            assert self._reload_tester(reloader, app_file)
+            assert caplog.records[-1].levelno == WARNING
+            assert (
+                caplog.records[-1].message == "WatchGodReload detected file change in "
+                f"'{[str(app_file)]}'. Reloading..."
+            )
+            assert self._reload_tester(reloader, app_first_file)
+            assert caplog.records[-1].levelno == WARNING
+            assert (
+                caplog.records[-1].message == "WatchGodReload detected file change in "
+                f"'{[str(app_first_file)]}'. Reloading..."
+            )
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [StatReload])
+    def test_should_print_full_path_for_non_relative(
+        self, caplog: pytest.LogCaptureFixture
+    ) -> None:
+        app_dir = self.reload_path / "app"
+        app_first_dir = self.reload_path / "app_first"
+        app_first_file = app_first_dir / "src" / "main.py"
+
+        with as_cwd(app_dir):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_dirs=[str(app_first_dir)],
+            )
+            reloader = self._setup_reloader(config)
+
+            assert self._reload_tester(reloader, app_first_file)
+
+            assert (
+                caplog.records[-1].message
+                == f"StatReload detected file change in '{str(app_first_file)}'."
+                " Reloading..."
+            )
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_detect_new_reload_dirs(
+        self, caplog: pytest.LogCaptureFixture, tmp_path: Path
+    ) -> None:
+        app_dir = tmp_path / "app"
+        app_file = app_dir / "file.py"
         app_dir.mkdir()
-        app_ext_dir.mkdir()
         app_file.touch()
-        app_ext_file.touch()
+        app_first_dir = tmp_path / "app_first"
+        app_first_file = app_first_dir / "file.py"
 
-        with self.tmpdir.as_cwd():
+        with as_cwd(tmp_path), caplog.at_level(INFO):
             config = Config(
-                app=None, reload=True, reload_dirs=[str(app_dir), str(app_ext_dir)]
+                app="tests.test_config:asgi_app", reload=True, reload_includes=["app*"]
             )
             reloader = self._setup_reloader(config)
-
             assert self._reload_tester(reloader, app_file)
-            assert self._reload_tester(reloader, app_ext_file)
+
+            app_first_dir.mkdir()
+            assert self._reload_tester(reloader, app_first_file)
+            assert caplog.records[-2].levelno == INFO
+            assert (
+                caplog.records[-2].message == "WatchGodReload detected a new reload "
+                f"dir '{app_first_dir.name}' in '{tmp_path}'; Adding to watch list."
+            )
+
+            reloader.shutdown()
+
+    @pytest.mark.parametrize("reloader_class", [WatchGodReload])
+    def test_should_detect_new_exclude_dirs(
+        self, caplog: pytest.LogCaptureFixture, tmp_path: Path
+    ) -> None:
+        app_dir = tmp_path / "app"
+        app_file = app_dir / "file.py"
+        app_dir.mkdir()
+        app_file.touch()
+        app_first_dir = tmp_path / "app_first"
+        app_first_file = app_first_dir / "file.py"
+
+        with as_cwd(tmp_path), caplog.at_level(DEBUG):
+            config = Config(
+                app="tests.test_config:asgi_app", reload=True, reload_excludes=["app*"]
+            )
+            reloader = self._setup_reloader(config)
+            caplog.set_level(DEBUG, logger="uvicorn.error")
+
+            assert not self._reload_tester(reloader, app_file)
+
+            app_first_dir.mkdir()
+            assert not self._reload_tester(reloader, app_first_file)
+            assert caplog.records[-1].levelno == DEBUG
+            assert (
+                caplog.records[-1].message == "WatchGodReload detected a new excluded "
+                f"dir '{app_first_dir.name}' in '{tmp_path}'; Adding to exclude list."
+            )
 
             reloader.shutdown()
diff --git a/tests/test_config.py b/tests/test_config.py
index 9efbd6c..2c1addb 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -18,6 +18,7 @@ import yaml
 from asgiref.typing import ASGIApplication, ASGIReceiveCallable, ASGISendCallable, Scope
 from pytest_mock import MockerFixture
 
+from tests.utils import as_cwd
 from uvicorn._types import Environ, StartResponse
 from uvicorn.config import LOGGING_CONFIG, Config
 from uvicorn.middleware.debug import DebugMiddleware
@@ -80,9 +81,155 @@ def test_config_should_reload_is_set(
     assert config_reload.should_reload is expected_should_reload
 
 
-def test_reload_dir_is_set() -> None:
-    config = Config(app=asgi_app, reload=True, reload_dirs="reload_me")
-    assert config.reload_dirs == ["reload_me"]
+def test_should_warn_on_invalid_reload_configuration(
+    tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+    config_class = Config(app=asgi_app, reload_dirs=[str(tmp_path)])
+    assert not config_class.should_reload
+    assert len(caplog.records) == 1
+    assert (
+        caplog.records[-1].message
+        == "Current configuration will not reload as not all conditions are met,"
+        "please refer to documentation."
+    )
+
+    config_no_reload = Config(
+        app="tests.test_config:asgi_app", reload_dirs=[str(tmp_path)]
+    )
+    assert not config_no_reload.should_reload
+    assert len(caplog.records) == 2
+    assert (
+        caplog.records[-1].message
+        == "Current configuration will not reload as not all conditions are met,"
+        "please refer to documentation."
+    )
+
+
+def test_reload_dir_is_set(
+    reload_directory_structure: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+    app_dir = reload_directory_structure / "app"
+    with caplog.at_level(logging.INFO):
+        config = Config(
+            app="tests.test_config:asgi_app", reload=True, reload_dirs=[str(app_dir)]
+        )
+        assert len(caplog.records) == 1
+        assert (
+            caplog.records[-1].message
+            == f"Will watch for changes in these directories: {[str(app_dir)]}"
+        )
+        assert config.reload_dirs == [app_dir]
+
+
+def test_non_existant_reload_dir_is_not_set(
+    reload_directory_structure: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+    with as_cwd(reload_directory_structure), caplog.at_level(logging.WARNING):
+        config = Config(
+            app="tests.test_config:asgi_app", reload=True, reload_dirs=["reload"]
+        )
+        assert config.reload_dirs == [reload_directory_structure]
+        assert (
+            caplog.records[-1].message
+            == "Provided reload directories ['reload'] did not contain valid "
+            + "directories, watching current working directory."
+        )
+
+
+def test_reload_subdir_removal(reload_directory_structure: Path) -> None:
+    app_dir = reload_directory_structure / "app"
+
+    reload_dirs = [str(reload_directory_structure), "app", str(app_dir)]
+
+    with as_cwd(reload_directory_structure):
+        config = Config(
+            app="tests.test_config:asgi_app", reload=True, reload_dirs=reload_dirs
+        )
+        assert config.reload_dirs == [reload_directory_structure]
+
+
+def test_reload_included_dir_is_added_to_reload_dirs(
+    reload_directory_structure: Path,
+) -> None:
+    app_dir = reload_directory_structure / "app"
+    ext_dir = reload_directory_structure / "ext"
+
+    with as_cwd(reload_directory_structure):
+        config = Config(
+            app="tests.test_config:asgi_app",
+            reload=True,
+            reload_dirs=[str(app_dir)],
+            reload_includes=["*.js", str(ext_dir)],
+        )
+        assert frozenset(config.reload_dirs), frozenset([app_dir, ext_dir])
+        assert frozenset(config.reload_includes) == frozenset(["*.js", str(ext_dir)])
+
+
+def test_reload_dir_subdirectories_are_removed(
+    reload_directory_structure: Path,
+) -> None:
+    app_dir = reload_directory_structure / "app"
+    app_sub_dir = app_dir / "sub"
+    ext_dir = reload_directory_structure / "ext"
+    ext_sub_dir = ext_dir / "sub"
+
+    with as_cwd(reload_directory_structure):
+        config = Config(
+            app="tests.test_config:asgi_app",
+            reload=True,
+            reload_dirs=[
+                str(app_dir),
+                str(app_sub_dir),
+                str(ext_sub_dir),
+                str(ext_dir),
+            ],
+        )
+        assert frozenset(config.reload_dirs) == frozenset([app_dir, ext_dir])
+
+
+def test_reload_excluded_subdirectories_are_removed(
+    reload_directory_structure: Path,
+) -> None:
+    app_dir = reload_directory_structure / "app"
+    app_sub_dir = app_dir / "sub"
+
+    with as_cwd(reload_directory_structure):
+        config = Config(
+            app="tests.test_config:asgi_app",
+            reload=True,
+            reload_excludes=[str(app_dir), str(app_sub_dir)],
+        )
+        assert frozenset(config.reload_dirs) == frozenset([reload_directory_structure])
+        assert frozenset(config.reload_dirs_excludes) == frozenset([app_dir])
+        assert frozenset(config.reload_excludes) == frozenset(
+            [str(app_dir), str(app_sub_dir)]
+        )
+
+
+def test_reload_includes_exclude_dir_patterns_are_matched(
+    reload_directory_structure: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+    with caplog.at_level(logging.INFO):
+        first_app_dir = reload_directory_structure / "app_first" / "src"
+        second_app_dir = reload_directory_structure / "app_second" / "src"
+
+        with as_cwd(reload_directory_structure):
+            config = Config(
+                app="tests.test_config:asgi_app",
+                reload=True,
+                reload_includes=["*/src"],
+                reload_excludes=["app", "*third*"],
+            )
+            assert len(caplog.records) == 1
+            assert (
+                caplog.records[-1].message
+                == "Will watch for changes in these directories: "
+                f"{sorted([str(first_app_dir), str(second_app_dir)])}"
+            )
+            assert frozenset(config.reload_dirs) == frozenset(
+                [first_app_dir, second_app_dir]
+            )
+            assert config.reload_includes == ["*/src"]
 
 
 def test_wsgi_app() -> None:
diff --git a/tests/utils.py b/tests/utils.py
index 32ade81..bbf7fd6 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -1,4 +1,7 @@
 import asyncio
+import os
+from contextlib import contextmanager
+from pathlib import Path
 
 try:
     from contextlib import asynccontextmanager
@@ -18,3 +21,14 @@ async def run_server(config: Config, sockets=None):
     finally:
         await server.shutdown()
         cancel_handle.cancel()
+
+
+@contextmanager
+def as_cwd(path: Path):
+    """Changes working directory and returns to previous on exit."""
+    prev_cwd = Path.cwd()
+    os.chdir(path)
+    try:
+        yield
+    finally:
+        os.chdir(prev_cwd)