Repository: webpack-contrib/copy-webpack-plugin
Pull request #393 (2019-07-19 18:18:39)

refactor: tests
<!--
  HOLY CRAP a Pull Request. We ❤️ those!

  If you remove or skip this template, you'll make the 🐼 sad and the mighty god
  of Github will appear and pile-drive the close button from a great height
  while making animal noises.

  Please place an x (no spaces!) in all [ ] that apply
-->

This PR contains a:

- [ ] **bugfix**
- [ ] new **feature**
- [x] **code refactor**
- [x] **test update** <!-- if bug or feature is checked, this should be too -->
- [ ] **typo fix**
- [ ] **metadata update**

### Motivation / Use-Case

Just test refactor

### Breaking Changes

No

### Additional Info

No

codecov[bot]: # [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=h1) Report
> Merging [#393](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=desc) into [master](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/commit/7f08be6e34e5776ac3b417d798b452d29d2ba3e3?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393/graphs/tree.svg?width=650&token=fEoU9iBFGL&height=150&src=pr)](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master     #393   +/-   ##
=======================================
  Coverage   94.88%   94.88%           
=======================================
  Files           9        9           
  Lines         293      293           
  Branches       78       78           
=======================================
  Hits          278      278           
  Misses         14       14           
  Partials        1        1
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=footer). Last update [7f08be6...92edab3](https://codecov.io/gh/webpack-contrib/copy-webpack-plugin/pull/393?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index 9623e65..4237189 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,8 @@ module.exports = {
 |         [`force`](#force)         |      `{Boolean}`      |                     `false`                     | Overwrites files already in `compilation.assets` (usually added by other plugins/loaders).            |
 |        [`ignore`](#ignore)        |       `{Array}`       |                      `[]`                       | Globs to ignore files.                                                                                |
 |       [`flatten`](#flatten)       |      `{Boolean}`      |                     `false`                     | Removes all directory references and only copies file names.                                          |
-|     [`transform`](#transform)     | `{Function\|Promise}` |                   `undefined`                   | Allows to modify the file contents.                                                                   |
 |         [`cache`](#cache)         |  `{Boolean\|Object}`  |                     `false`                     | Enable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache. |
+|     [`transform`](#transform)     | `{Function\|Promise}` |                   `undefined`                   | Allows to modify the file contents.                                                                   |
 | [`transformPath`](#transformPath) | `{Function\|Promise}` |                   `undefined`                   | Allows to modify the writing path.                                                                    |
 
 #### `from`
@@ -324,14 +324,13 @@ module.exports = {
 };
 ```
 
-#### `transform`
-
-Type: `Function|Promise`
-Default: `undefined`
+#### `cache`
 
-Allows to modify the file contents.
+Type: `Boolean|Object`
+Default: `false`
 
-##### `{Function}`
+Enable/disable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache.
+Default path to cache directory: `node_modules/.cache/copy-webpack-plugin`.
 
 **webpack.config.js**
 
@@ -345,13 +344,21 @@ module.exports = {
         transform(content, path) {
           return optimize(content);
         },
+        cache: true,
       },
     ]),
   ],
 };
 ```
 
-##### `{Promise}`
+#### `transform`
+
+Type: `Function|Promise`
+Default: `undefined`
+
+Allows to modify the file contents.
+
+##### `{Function}`
 
 **webpack.config.js**
 
@@ -363,7 +370,7 @@ module.exports = {
         from: 'src/*.png',
         to: 'dest/',
         transform(content, path) {
-          return Promise.resolve(optimize(content));
+          return optimize(content);
         },
       },
     ]),
@@ -371,13 +378,7 @@ module.exports = {
 };
 ```
 
-#### `cache`
-
-Type: `Boolean|Object`
-Default: `false`
-
-Enable/disable `transform` caching. You can use `{ cache: { key: 'my-cache-key' } }` to invalidate the cache.
-Default path to cache directory: `node_modules/.cache/copy-webpack-plugin`.
+##### `{Promise}`
 
 **webpack.config.js**
 
@@ -389,9 +390,8 @@ module.exports = {
         from: 'src/*.png',
         to: 'dest/',
         transform(content, path) {
-          return optimize(content);
+          return Promise.resolve(optimize(content));
         },
-        cache: true,
       },
     ]),
   ],


Test patch:
diff --git a/test/CopyPlugin.test.js b/test/CopyPlugin.test.js
index a9519ed..d54b842 100644
--- a/test/CopyPlugin.test.js
+++ b/test/CopyPlugin.test.js
@@ -2,9 +2,6 @@ import fs from 'fs';
 import path from 'path';
 import zlib from 'zlib';
 
-import NodeJsInputFileSystem from 'enhanced-resolve/lib/NodeJsInputFileSystem';
-import CachedInputFileSystem from 'enhanced-resolve/lib/CachedInputFileSystem';
-
 import findCacheDir from 'find-cache-dir';
 import cacache from 'cacache';
 import isGzip from 'is-gzip';
@@ -14,244 +11,14 @@ import CopyPlugin from '../src/index';
 
 import removeIllegalCharacterForWindows from './utils/removeIllegalCharacterForWindows';
 
+import { MockCompiler, MockCompilerNoStat } from './utils/mocks';
+import { run, runEmit, runForce, runChange } from './utils/run';
+
 const BUILD_DIR = path.join(__dirname, 'build');
 const HELPER_DIR = path.join(__dirname, 'helpers');
 const TEMP_DIR = path.join(__dirname, 'tempdir');
 
-class MockCompiler {
-  constructor(options = {}) {
-    this.options = {
-      context: HELPER_DIR,
-      output: {
-        path: options.outputPath || BUILD_DIR,
-      },
-    };
-
-    if (options.devServer && options.devServer.outputPath) {
-      this.options.devServer = {
-        outputPath: options.devServer.outputPath,
-      };
-    }
-
-    this.inputFileSystem = new CachedInputFileSystem(
-      new NodeJsInputFileSystem(),
-      0
-    );
-
-    this.hooks = {
-      emit: {
-        tapAsync: (plugin, fn) => {
-          this.hooks.emit = fn;
-        },
-      },
-      afterEmit: {
-        tapAsync: (plugin, fn) => {
-          this.hooks.afterEmit = fn;
-        },
-      },
-    };
-
-    this.outputFileSystem = {
-      constructor: {
-        name: 'NotMemoryFileSystem',
-      },
-    };
-  }
-}
-
-class MockCompilerNoStat extends MockCompiler {
-  constructor(options = {}) {
-    super(options);
-
-    // eslint-disable-next-line no-undefined
-    this.inputFileSystem.stat = (file, cb) => cb(undefined, undefined);
-  }
-}
-
 describe('apply function', () => {
-  // Ideally we pass in patterns and confirm the resulting assets
-  const run = (opts) =>
-    new Promise((resolve, reject) => {
-      if (Array.isArray(opts.patterns)) {
-        opts.patterns.forEach((pattern) => {
-          if (pattern.context) {
-            // eslint-disable-next-line no-param-reassign
-            pattern.context = removeIllegalCharacterForWindows(pattern.context);
-          }
-        });
-      }
-
-      // Get a mock compiler to pass to plugin.apply
-      const compiler = opts.compiler || new MockCompiler();
-
-      const isWin = process.platform === 'win32';
-
-      if (!opts.symlink || isWin) {
-        if (!opts.options) {
-          // eslint-disable-next-line no-param-reassign
-          opts.options = {};
-        }
-
-        if (!opts.options.ignore) {
-          // eslint-disable-next-line no-param-reassign
-          opts.options.ignore = [];
-        }
-
-        opts.options.ignore.push('symlink/**/*', 'file-ln.txt', 'directory-ln');
-      }
-
-      new CopyPlugin(opts.patterns, opts.options).apply(compiler);
-
-      // Call the registered function with a mock compilation and callback
-      const compilation = Object.assign(
-        {
-          assets: {},
-          errors: [],
-          warnings: [],
-          fileDependencies: new Set(),
-          contextDependencies: new Set(),
-        },
-        opts.compilation
-      );
-
-      // Execute the functions in series
-      return Promise.resolve()
-        .then(
-          () =>
-            new Promise((res, rej) => {
-              try {
-                compiler.hooks.emit(compilation, res);
-              } catch (error) {
-                rej(error);
-              }
-            })
-        )
-        .then(
-          () =>
-            new Promise((res, rej) => {
-              try {
-                compiler.hooks.afterEmit(compilation, res);
-              } catch (error) {
-                rej(error);
-              }
-            })
-        )
-        .then(() => {
-          if (opts.expectedErrors) {
-            expect(compilation.errors).toEqual(opts.expectedErrors);
-          } else if (compilation.errors.length > 0) {
-            throw compilation.errors[0];
-          }
-
-          if (opts.expectedWarnings) {
-            expect(compilation.warnings).toEqual(opts.expectedWarnings);
-          } else if (compilation.warnings.length > 0) {
-            throw compilation.warnings[0];
-          }
-
-          resolve(compilation);
-        })
-        .catch(reject);
-    });
-
-  const runEmit = (opts) =>
-    run(opts).then((compilation) => {
-      if (opts.skipAssetsTesting) {
-        return;
-      }
-
-      if (opts.expectedAssetKeys && opts.expectedAssetKeys.length > 0) {
-        expect(Object.keys(compilation.assets).sort()).toEqual(
-          opts.expectedAssetKeys.sort().map(removeIllegalCharacterForWindows)
-        );
-      } else {
-        expect(compilation.assets).toEqual({});
-      }
-
-      if (opts.expectedAssetContent) {
-        // eslint-disable-next-line guard-for-in
-        for (const assetName in opts.expectedAssetContent) {
-          expect(compilation.assets[assetName]).toBeDefined();
-
-          if (compilation.assets[assetName]) {
-            let expectedContent = opts.expectedAssetContent[assetName];
-
-            if (!Buffer.isBuffer(expectedContent)) {
-              expectedContent = Buffer.from(expectedContent);
-            }
-
-            let compiledContent = compilation.assets[assetName].source();
-
-            if (!Buffer.isBuffer(compiledContent)) {
-              compiledContent = Buffer.from(compiledContent);
-            }
-
-            expect(Buffer.compare(expectedContent, compiledContent)).toBe(0);
-          }
-        }
-      }
-    });
-
-  const runForce = (opts) => {
-    // eslint-disable-next-line no-param-reassign
-    opts.compilation = {
-      assets: {},
-    };
-    // eslint-disable-next-line no-param-reassign
-    opts.compilation.assets[opts.existingAsset] = {
-      source() {
-        return 'existing';
-      },
-    };
-
-    return run(opts).then(() => {});
-  };
-
-  const runChange = (opts) => {
-    // Create two test files
-    fs.writeFileSync(opts.newFileLoc1, 'file1contents');
-    fs.writeFileSync(opts.newFileLoc2, 'file2contents');
-
-    // Create a reference to the compiler
-    const compiler = new MockCompiler();
-    const compilation = {
-      assets: {},
-      errors: [],
-      warnings: [],
-      fileDependencies: new Set(),
-      contextDependencies: new Set(),
-    };
-
-    return run({
-      compiler,
-      options: opts.options,
-      patterns: opts.patterns,
-    })
-      .then(() => {
-        // Change a file
-        fs.appendFileSync(opts.newFileLoc1, 'extra');
-
-        // Trigger another compile
-        return new Promise((res) => {
-          compiler.hooks.emit(compilation, res);
-        });
-      })
-      .then(() => {
-        if (opts.expectedAssetKeys && opts.expectedAssetKeys.length > 0) {
-          expect(Object.keys(compilation.assets).sort()).toEqual(
-            opts.expectedAssetKeys.sort().map(removeIllegalCharacterForWindows)
-          );
-        } else {
-          expect(compilation.assets).toEqual({});
-        }
-      })
-      .then(() => {
-        // Todo finally and check file exists
-        fs.unlinkSync(opts.newFileLoc1);
-        fs.unlinkSync(opts.newFileLoc2);
-      });
-  };
-
   const specialFiles = {
     '[special?directory]/nested/nestedfile.txt': '',
     '[special?directory]/(special-*file).txt': 'special',
@@ -434,72 +201,6 @@ describe('apply function', () => {
         .catch(done);
     });
 
-    it('can transform target path of every file in glob', (done) => {
-      runEmit({
-        expectedAssetKeys: [
-          '/some/path/(special-*file).txt.tst',
-          '/some/path/binextension.bin.tst',
-          '/some/path/deepnested.txt.tst',
-          '/some/path/deepnesteddir.txt.tst',
-          '/some/path/file.txt.tst',
-          '/some/path/file.txt.gz.tst',
-          '/some/path/directoryfile.txt.tst',
-          '/some/path/nestedfile.txt.tst',
-          '/some/path/noextension.tst',
-          '/some/path/hello.txt.tst',
-        ],
-        patterns: [
-          {
-            from: '**/*',
-            transformPath(targetPath, absoluteFrom) {
-              expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
-
-              return `/some/path/${path.basename(targetPath)}.tst`;
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
-    it('can transform target path of every file in glob after applying template', (done) => {
-      runEmit({
-        expectedAssetKeys: [
-          'transformed/[!]/hello-d41d8c.txt',
-          'transformed/[special?directory]/directoryfile-22af64.txt',
-          'transformed/[special?directory]/(special-*file)-0bd650.txt',
-          'transformed/[special?directory]/nested/nestedfile-d41d8c.txt',
-          'transformed/binextension-d41d8c.bin',
-          'transformed/dir (86)/file-d41d8c.txt',
-          'transformed/dir (86)/nesteddir/deepnesteddir/deepnesteddir-d41d8c.txt',
-          'transformed/dir (86)/nesteddir/nestedfile-d41d8c.txt',
-          'transformed/file-22af64.txt',
-          'transformed/file.txt-5b311c.gz',
-          'transformed/directory/directoryfile-22af64.txt',
-          'transformed/directory/nested/deep-nested/deepnested-d41d8c.txt',
-          'transformed/directory/nested/nestedfile-d41d8c.txt',
-          'transformed/noextension-d41d8c',
-        ],
-        patterns: [
-          {
-            from: '**/*',
-            to: 'nested/[path][name]-[hash:6].[ext]',
-            transformPath(targetPath, absoluteFrom) {
-              expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
-
-              return targetPath.replace(
-                `nested${path.sep}`,
-                `transformed${path.sep}`
-              );
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
     it('can use a glob to move multiple files in a different relative context to a non-root directory', (done) => {
       runEmit({
         expectedAssetKeys: [
@@ -780,24 +481,6 @@ describe('apply function', () => {
         .catch(done);
     });
 
-    it('can transform target path', (done) => {
-      runEmit({
-        expectedAssetKeys: ['subdir/test.txt'],
-        patterns: [
-          {
-            from: 'file.txt',
-            transformPath(targetPath, absoluteFrom) {
-              expect(absoluteFrom).toBe(path.join(HELPER_DIR, 'file.txt'));
-
-              return targetPath.replace('file.txt', 'subdir/test.txt');
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
     it('warns when file not found', (done) => {
       runEmit({
         expectedAssetKeys: [],
@@ -855,24 +538,6 @@ describe('apply function', () => {
         .catch(done);
     });
 
-    it('warns when tranformPath failed', (done) => {
-      runEmit({
-        expectedAssetKeys: [],
-        expectedErrors: ['a failure happened'],
-        patterns: [
-          {
-            from: 'file.txt',
-            transformPath() {
-              // eslint-disable-next-line no-throw-literal
-              throw 'a failure happened';
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
     it('warns when pattern is empty', (done) => {
       runEmit({
         expectedAssetKeys: [
@@ -1386,26 +1051,6 @@ describe('apply function', () => {
         .catch(done);
     });
 
-    it('transformPath with promise', (done) => {
-      runEmit({
-        expectedAssetKeys: ['/some/path/file.txt'],
-        patterns: [
-          {
-            from: 'file.txt',
-            transformPath(targetPath, absoluteFrom) {
-              expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
-
-              return new Promise((resolve) => {
-                resolve(`/some/path/${path.basename(targetPath)}`);
-              });
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
     it('same file to multiple targets', (done) => {
       runEmit({
         expectedAssetKeys: ['first/file.txt', 'second/file.txt'],
@@ -1459,31 +1104,6 @@ describe('apply function', () => {
         .catch(done);
     });
 
-    it('can transform target path of every file in directory', (done) => {
-      runEmit({
-        expectedAssetKeys: [
-          '/some/path/.dottedfile',
-          '/some/path/deepnested.txt',
-          '/some/path/directoryfile.txt',
-          '/some/path/nestedfile.txt',
-        ],
-        patterns: [
-          {
-            from: 'directory',
-            transformPath(targetPath, absoluteFrom) {
-              expect(
-                absoluteFrom.includes(path.join(HELPER_DIR, 'directory'))
-              ).toBe(true);
-
-              return `/some/path/${path.basename(targetPath)}`;
-            },
-          },
-        ],
-      })
-        .then(done)
-        .catch(done);
-    });
-
     it("can move a directory's contents to the root directory using from with special characters", (done) => {
       runEmit({
         expectedAssetKeys: [
diff --git a/test/transformPath-option.test.js b/test/transformPath-option.test.js
new file mode 100644
index 0000000..3ab1b16
--- /dev/null
+++ b/test/transformPath-option.test.js
@@ -0,0 +1,213 @@
+import path from 'path';
+
+import { runEmit } from './utils/run';
+
+const HELPER_DIR = path.join(__dirname, 'helpers');
+
+describe('transformPath option', () => {
+  it('should transform target path when "from" is a file', (done) => {
+    runEmit({
+      expectedAssetKeys: ['subdir/test.txt'],
+      patterns: [
+        {
+          from: 'file.txt',
+          transformPath(targetPath, absoluteFrom) {
+            expect(absoluteFrom).toBe(path.join(HELPER_DIR, 'file.txt'));
+
+            return targetPath.replace('file.txt', 'subdir/test.txt');
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should transform target path of every when "from" is a directory', (done) => {
+    runEmit({
+      expectedAssetKeys: [
+        '/some/path/.dottedfile',
+        '/some/path/deepnested.txt',
+        '/some/path/directoryfile.txt',
+        '/some/path/nestedfile.txt',
+      ],
+      patterns: [
+        {
+          from: 'directory',
+          transformPath(targetPath, absoluteFrom) {
+            expect(
+              absoluteFrom.includes(path.join(HELPER_DIR, 'directory'))
+            ).toBe(true);
+
+            return `/some/path/${path.basename(targetPath)}`;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should transform target path of every file when "from" is a glob', (done) => {
+    runEmit({
+      expectedAssetKeys: [
+        '/some/path/(special-*file).txt.tst',
+        '/some/path/binextension.bin.tst',
+        '/some/path/deepnested.txt.tst',
+        '/some/path/deepnesteddir.txt.tst',
+        '/some/path/file.txt.tst',
+        '/some/path/file.txt.gz.tst',
+        '/some/path/directoryfile.txt.tst',
+        '/some/path/nestedfile.txt.tst',
+        '/some/path/noextension.tst',
+        '/some/path/hello.txt.tst',
+      ],
+      patterns: [
+        {
+          from: '**/*',
+          transformPath(targetPath, absoluteFrom) {
+            expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
+
+            return `/some/path/${path.basename(targetPath)}.tst`;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should transform target path when function return Promise', (done) => {
+    runEmit({
+      expectedAssetKeys: ['/some/path/file.txt'],
+      patterns: [
+        {
+          from: 'file.txt',
+          transformPath(targetPath, absoluteFrom) {
+            expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
+
+            return new Promise((resolve) => {
+              resolve(`/some/path/${path.basename(targetPath)}`);
+            });
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should transform target path when async function used', (done) => {
+    runEmit({
+      expectedAssetKeys: ['/some/path/file.txt'],
+      patterns: [
+        {
+          from: 'file.txt',
+          async transformPath(targetPath, absoluteFrom) {
+            expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
+
+            const newPath = await new Promise((resolve) => {
+              resolve(`/some/path/${path.basename(targetPath)}`);
+            });
+
+            return newPath;
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should warns when function throw error', (done) => {
+    runEmit({
+      expectedAssetKeys: [],
+      expectedErrors: [new Error('a failure happened')],
+      patterns: [
+        {
+          from: 'file.txt',
+          transformPath() {
+            throw new Error('a failure happened');
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should warns when Promise was rejected', (done) => {
+    runEmit({
+      expectedAssetKeys: [],
+      expectedErrors: [new Error('a failure happened')],
+      patterns: [
+        {
+          from: 'file.txt',
+          transformPath() {
+            return new Promise((resolve, reject) => {
+              return reject(new Error('a failure happened'));
+            });
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should warns when async function throw error', (done) => {
+    runEmit({
+      expectedAssetKeys: [],
+      expectedErrors: [new Error('a failure happened')],
+      patterns: [
+        {
+          from: 'file.txt',
+          async transformPath() {
+            await new Promise((resolve, reject) => {
+              reject(new Error('a failure happened'));
+            });
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+
+  it('should transform target path of every file in glob after applying template', (done) => {
+    runEmit({
+      expectedAssetKeys: [
+        'transformed/[!]/hello-d41d8c.txt',
+        'transformed/[special?directory]/directoryfile-22af64.txt',
+        'transformed/[special?directory]/(special-*file)-0bd650.txt',
+        'transformed/[special?directory]/nested/nestedfile-d41d8c.txt',
+        'transformed/binextension-d41d8c.bin',
+        'transformed/dir (86)/file-d41d8c.txt',
+        'transformed/dir (86)/nesteddir/deepnesteddir/deepnesteddir-d41d8c.txt',
+        'transformed/dir (86)/nesteddir/nestedfile-d41d8c.txt',
+        'transformed/file-22af64.txt',
+        'transformed/file.txt-5b311c.gz',
+        'transformed/directory/directoryfile-22af64.txt',
+        'transformed/directory/nested/deep-nested/deepnested-d41d8c.txt',
+        'transformed/directory/nested/nestedfile-d41d8c.txt',
+        'transformed/noextension-d41d8c',
+      ],
+      patterns: [
+        {
+          from: '**/*',
+          to: 'nested/[path][name]-[hash:6].[ext]',
+          transformPath(targetPath, absoluteFrom) {
+            expect(absoluteFrom.includes(HELPER_DIR)).toBe(true);
+
+            return targetPath.replace(
+              `nested${path.sep}`,
+              `transformed${path.sep}`
+            );
+          },
+        },
+      ],
+    })
+      .then(done)
+      .catch(done);
+  });
+});
diff --git a/test/utils/mocks.js b/test/utils/mocks.js
new file mode 100644
index 0000000..358d2e5
--- /dev/null
+++ b/test/utils/mocks.js
@@ -0,0 +1,59 @@
+import path from 'path';
+
+import CachedInputFileSystem from 'enhanced-resolve/lib/CachedInputFileSystem';
+import NodeJsInputFileSystem from 'enhanced-resolve/lib/NodeJsInputFileSystem';
+
+const BUILD_DIR = path.join(__dirname, '../build');
+const HELPER_DIR = path.join(__dirname, '../helpers');
+
+class MockCompiler {
+  constructor(options = {}) {
+    this.options = {
+      context: HELPER_DIR,
+      output: {
+        path: options.outputPath || BUILD_DIR,
+      },
+    };
+
+    if (options.devServer && options.devServer.outputPath) {
+      this.options.devServer = {
+        outputPath: options.devServer.outputPath,
+      };
+    }
+
+    this.inputFileSystem = new CachedInputFileSystem(
+      new NodeJsInputFileSystem(),
+      0
+    );
+
+    this.hooks = {
+      emit: {
+        tapAsync: (plugin, fn) => {
+          this.hooks.emit = fn;
+        },
+      },
+      afterEmit: {
+        tapAsync: (plugin, fn) => {
+          this.hooks.afterEmit = fn;
+        },
+      },
+    };
+
+    this.outputFileSystem = {
+      constructor: {
+        name: 'NotMemoryFileSystem',
+      },
+    };
+  }
+}
+
+class MockCompilerNoStat extends MockCompiler {
+  constructor(options = {}) {
+    super(options);
+
+    // eslint-disable-next-line no-undefined
+    this.inputFileSystem.stat = (file, cb) => cb(undefined, undefined);
+  }
+}
+
+export { MockCompiler, MockCompilerNoStat };
diff --git a/test/utils/run.js b/test/utils/run.js
new file mode 100644
index 0000000..17cf14f
--- /dev/null
+++ b/test/utils/run.js
@@ -0,0 +1,194 @@
+// Ideally we pass in patterns and confirm the resulting assets
+import fs from 'fs';
+
+import CopyPlugin from '../../src';
+
+import removeIllegalCharacterForWindows from './removeIllegalCharacterForWindows';
+
+import { MockCompiler } from './mocks';
+
+function run(opts) {
+  return new Promise((resolve, reject) => {
+    if (Array.isArray(opts.patterns)) {
+      opts.patterns.forEach((pattern) => {
+        if (pattern.context) {
+          // eslint-disable-next-line no-param-reassign
+          pattern.context = removeIllegalCharacterForWindows(pattern.context);
+        }
+      });
+    }
+
+    // Get a mock compiler to pass to plugin.apply
+    const compiler = opts.compiler || new MockCompiler();
+
+    const isWin = process.platform === 'win32';
+
+    if (!opts.symlink || isWin) {
+      if (!opts.options) {
+        // eslint-disable-next-line no-param-reassign
+        opts.options = {};
+      }
+
+      if (!opts.options.ignore) {
+        // eslint-disable-next-line no-param-reassign
+        opts.options.ignore = [];
+      }
+
+      opts.options.ignore.push('symlink/**/*', 'file-ln.txt', 'directory-ln');
+    }
+
+    new CopyPlugin(opts.patterns, opts.options).apply(compiler);
+
+    // Call the registered function with a mock compilation and callback
+    const compilation = Object.assign(
+      {
+        assets: {},
+        errors: [],
+        warnings: [],
+        fileDependencies: new Set(),
+        contextDependencies: new Set(),
+      },
+      opts.compilation
+    );
+
+    // Execute the functions in series
+    return Promise.resolve()
+      .then(
+        () =>
+          new Promise((res, rej) => {
+            try {
+              compiler.hooks.emit(compilation, res);
+            } catch (error) {
+              rej(error);
+            }
+          })
+      )
+      .then(
+        () =>
+          new Promise((res, rej) => {
+            try {
+              compiler.hooks.afterEmit(compilation, res);
+            } catch (error) {
+              rej(error);
+            }
+          })
+      )
+      .then(() => {
+        if (opts.expectedErrors) {
+          expect(compilation.errors).toEqual(opts.expectedErrors);
+        } else if (compilation.errors.length > 0) {
+          throw compilation.errors[0];
+        }
+
+        if (opts.expectedWarnings) {
+          expect(compilation.warnings).toEqual(opts.expectedWarnings);
+        } else if (compilation.warnings.length > 0) {
+          throw compilation.warnings[0];
+        }
+
+        resolve(compilation);
+      })
+      .catch(reject);
+  });
+}
+
+function runEmit(opts) {
+  return run(opts).then((compilation) => {
+    if (opts.skipAssetsTesting) {
+      return;
+    }
+
+    if (opts.expectedAssetKeys && opts.expectedAssetKeys.length > 0) {
+      expect(Object.keys(compilation.assets).sort()).toEqual(
+        opts.expectedAssetKeys.sort().map(removeIllegalCharacterForWindows)
+      );
+    } else {
+      expect(compilation.assets).toEqual({});
+    }
+
+    if (opts.expectedAssetContent) {
+      // eslint-disable-next-line guard-for-in
+      for (const assetName in opts.expectedAssetContent) {
+        expect(compilation.assets[assetName]).toBeDefined();
+
+        if (compilation.assets[assetName]) {
+          let expectedContent = opts.expectedAssetContent[assetName];
+
+          if (!Buffer.isBuffer(expectedContent)) {
+            expectedContent = Buffer.from(expectedContent);
+          }
+
+          let compiledContent = compilation.assets[assetName].source();
+
+          if (!Buffer.isBuffer(compiledContent)) {
+            compiledContent = Buffer.from(compiledContent);
+          }
+
+          expect(Buffer.compare(expectedContent, compiledContent)).toBe(0);
+        }
+      }
+    }
+  });
+}
+
+function runForce(opts) {
+  // eslint-disable-next-line no-param-reassign
+  opts.compilation = {
+    assets: {},
+  };
+  // eslint-disable-next-line no-param-reassign
+  opts.compilation.assets[opts.existingAsset] = {
+    source() {
+      return 'existing';
+    },
+  };
+
+  return run(opts).then(() => {});
+}
+
+function runChange(opts) {
+  // Create two test files
+  fs.writeFileSync(opts.newFileLoc1, 'file1contents');
+  fs.writeFileSync(opts.newFileLoc2, 'file2contents');
+
+  // Create a reference to the compiler
+  const compiler = new MockCompiler();
+  const compilation = {
+    assets: {},
+    errors: [],
+    warnings: [],
+    fileDependencies: new Set(),
+    contextDependencies: new Set(),
+  };
+
+  return run({
+    compiler,
+    options: opts.options,
+    patterns: opts.patterns,
+  })
+    .then(() => {
+      // Change a file
+      fs.appendFileSync(opts.newFileLoc1, 'extra');
+
+      // Trigger another compile
+      return new Promise((res) => {
+        compiler.hooks.emit(compilation, res);
+      });
+    })
+    .then(() => {
+      if (opts.expectedAssetKeys && opts.expectedAssetKeys.length > 0) {
+        expect(Object.keys(compilation.assets).sort()).toEqual(
+          opts.expectedAssetKeys.sort().map(removeIllegalCharacterForWindows)
+        );
+      } else {
+        expect(compilation.assets).toEqual({});
+      }
+    })
+    .then(() => {
+      // Todo finally and check file exists
+      fs.unlinkSync(opts.newFileLoc1);
+      fs.unlinkSync(opts.newFileLoc2);
+    });
+}
+
+export { run, runChange, runEmit, runForce };