diff --git a/core/modules/image/tests/modules/image_module_test/image_module_test.module b/core/modules/image/tests/modules/image_module_test/image_module_test.module
index 20c515e9da..bad7382488 100644
--- a/core/modules/image/tests/modules/image_module_test/image_module_test.module
+++ b/core/modules/image/tests/modules/image_module_test/image_module_test.module
@@ -16,12 +16,18 @@ function image_module_test_file_download($uri) {
 
 /**
  * Implements hook_image_effect_info_alter().
- *
- * Used to keep a count of cache misses in \Drupal\image\ImageEffectManager.
  */
 function image_module_test_image_effect_info_alter(&$effects) {
-  $image_effects_definition_called = &drupal_static(__FUNCTION__, 0);
-  $image_effects_definition_called++;
+  $state = \Drupal::state();
+  // The 'image_module_test.counter' state variable value is set and accessed
+  // from the ImageEffectsTest::testImageEffectsCaching() test and used to
+  // signal if the image effect plugin definitions were computed or were
+  // retrieved from the cache.
+  // @see \Drupal\Tests\image\Kernel\ImageEffectsTest::testImageEffectsCaching()
+  $counter = $state->get('image_module_test.counter');
+  // Increase the test counter, signaling that image effects were processed,
+  // rather than being served from the cache.
+  $state->set('image_module_test.counter', ++$counter);
 }
 
 /**
diff --git a/core/modules/image/tests/src/Functional/ImageEffectsTest.php b/core/modules/image/tests/src/Functional/ImageEffectsTest.php
deleted file mode 100644
index f6b4ab36e9..0000000000
--- a/core/modules/image/tests/src/Functional/ImageEffectsTest.php
+++ /dev/null
@@ -1,223 +0,0 @@
-<?php
-
-namespace Drupal\Tests\image\Functional;
-
-use Drupal\image\Entity\ImageStyle;
-use Drupal\FunctionalTests\Image\ToolkitTestBase;
-
-/**
- * Tests that the image effects pass parameters to the toolkit correctly.
- *
- * @group image
- */
-class ImageEffectsTest extends ToolkitTestBase {
-
-  /**
-   * Modules to enable.
-   *
-   * @var array
-   */
-  public static $modules = ['image', 'image_test', 'image_module_test'];
-
-  /**
-   * The image effect manager.
-   *
-   * @var \Drupal\image\ImageEffectManager
-   */
-  protected $manager;
-
-  protected function setUp() {
-    parent::setUp();
-    $this->manager = $this->container->get('plugin.manager.image.effect');
-  }
-
-  /**
-   * Test the image_resize_effect() function.
-   */
-  public function testResizeEffect() {
-    $this->assertImageEffect('image_resize', [
-      'width' => 1,
-      'height' => 2,
-    ]);
-    $this->assertToolkitOperationsCalled(['resize']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['resize'][0][0], 1, 'Width was passed correctly');
-    $this->assertEqual($calls['resize'][0][1], 2, 'Height was passed correctly');
-  }
-
-  /**
-   * Test the image_scale_effect() function.
-   */
-  public function testScaleEffect() {
-    // @todo: need to test upscaling.
-    $this->assertImageEffect('image_scale', [
-      'width' => 10,
-      'height' => 10,
-    ]);
-    $this->assertToolkitOperationsCalled(['scale']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['scale'][0][0], 10, 'Width was passed correctly');
-    $this->assertEqual($calls['scale'][0][1], 10, 'Height was based off aspect ratio and passed correctly');
-  }
-
-  /**
-   * Test the image_crop_effect() function.
-   */
-  public function testCropEffect() {
-    // @todo should test the keyword offsets.
-    $this->assertImageEffect('image_crop', [
-      'anchor' => 'top-1',
-      'width' => 3,
-      'height' => 4,
-    ]);
-    $this->assertToolkitOperationsCalled(['crop']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['crop'][0][0], 0, 'X was passed correctly');
-    $this->assertEqual($calls['crop'][0][1], 1, 'Y was passed correctly');
-    $this->assertEqual($calls['crop'][0][2], 3, 'Width was passed correctly');
-    $this->assertEqual($calls['crop'][0][3], 4, 'Height was passed correctly');
-  }
-
-  /**
-   * Tests the ConvertImageEffect plugin.
-   */
-  public function testConvertEffect() {
-    // Test jpeg.
-    $this->assertImageEffect('image_convert', [
-      'extension' => 'jpeg',
-    ]);
-    $this->assertToolkitOperationsCalled(['convert']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['convert'][0][0], 'jpeg', 'Extension was passed correctly');
-  }
-
-  /**
-   * Test the image_scale_and_crop_effect() function.
-   */
-  public function testScaleAndCropEffect() {
-    $this->assertImageEffect('image_scale_and_crop', [
-      'width' => 5,
-      'height' => 10,
-    ]);
-    $this->assertToolkitOperationsCalled(['scale_and_crop']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['scale_and_crop'][0][0], 7.5, 'X was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][1], 0, 'Y was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][2], 5, 'Width was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][3], 10, 'Height was computed and passed correctly');
-  }
-
-  /**
-   * Test the image_scale_and_crop_effect() function with an anchor.
-   */
-  public function testScaleAndCropEffectWithAnchor() {
-    $this->assertImageEffect('image_scale_and_crop', [
-      'anchor' => 'top-1',
-      'width' => 5,
-      'height' => 10,
-    ]);
-    $this->assertToolkitOperationsCalled(['scale_and_crop']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['scale_and_crop'][0][0], 0, 'X was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][1], 1, 'Y was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][2], 5, 'Width was computed and passed correctly');
-    $this->assertEqual($calls['scale_and_crop'][0][3], 10, 'Height was computed and passed correctly');
-  }
-
-  /**
-   * Test the image_desaturate_effect() function.
-   */
-  public function testDesaturateEffect() {
-    $this->assertImageEffect('image_desaturate', []);
-    $this->assertToolkitOperationsCalled(['desaturate']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual(count($calls['desaturate'][0]), 0, 'No parameters were passed.');
-  }
-
-  /**
-   * Test the image_rotate_effect() function.
-   */
-  public function testRotateEffect() {
-    // @todo: need to test with 'random' => TRUE
-    $this->assertImageEffect('image_rotate', [
-      'degrees' => 90,
-      'bgcolor' => '#fff',
-    ]);
-    $this->assertToolkitOperationsCalled(['rotate']);
-
-    // Check the parameters.
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['rotate'][0][0], 90, 'Degrees were passed correctly');
-    $this->assertEqual($calls['rotate'][0][1], '#fff', 'Background color was passed correctly');
-  }
-
-  /**
-   * Test image effect caching.
-   */
-  public function testImageEffectsCaching() {
-    $image_effect_definitions_called = &drupal_static('image_module_test_image_effect_info_alter');
-
-    // First call should grab a fresh copy of the data.
-    $manager = $this->container->get('plugin.manager.image.effect');
-    $effects = $manager->getDefinitions();
-    $this->assertTrue($image_effect_definitions_called === 1, 'image_effect_definitions() generated data.');
-
-    // Second call should come from cache.
-    drupal_static_reset('image_module_test_image_effect_info_alter');
-    $cached_effects = $manager->getDefinitions();
-    $this->assertTrue($image_effect_definitions_called === 0, 'image_effect_definitions() returned data from cache.');
-
-    $this->assertTrue($effects == $cached_effects, 'Cached effects are the same as generated effects.');
-  }
-
-  /**
-   * Tests if validation errors are passed plugin form to the parent form.
-   */
-  public function testEffectFormValidationErrors() {
-    $account = $this->drupalCreateUser(['administer image styles']);
-    $this->drupalLogin($account);
-    /** @var \Drupal\image\ImageStyleInterface $style */
-    $style = ImageStyle::load('thumbnail');
-    // Image Scale is the only effect shipped with 'thumbnail', by default.
-    $uuids = $style->getEffects()->getInstanceIds();
-    $uuid = key($uuids);
-
-    // We are posting the form with both, width and height, empty.
-    $edit = ['data[width]' => '', 'data[height]' => ''];
-    $path = 'admin/config/media/image-styles/manage/thumbnail/effects/' . $uuid;
-    $this->drupalPostForm($path, $edit, t('Update effect'));
-    // Check that the error message has been displayed.
-    $this->assertText(t('Width and height can not both be blank.'));
-  }
-
-  /**
-   * Asserts the effect processing of an image effect plugin.
-   *
-   * @param string $effect_name
-   *   The name of the image effect to test.
-   * @param array $data
-   *   The data to pass to the image effect.
-   *
-   * @return bool
-   *   TRUE if the assertion succeeded, FALSE otherwise.
-   */
-  protected function assertImageEffect($effect_name, array $data) {
-    $effect = $this->manager->createInstance($effect_name, ['data' => $data]);
-    return $this->assertTrue($effect->applyEffect($this->image), 'Function returned the expected value.');
-  }
-
-}
diff --git a/core/modules/image/tests/src/Kernel/ImageEffectsTest.php b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php
new file mode 100644
index 0000000000..de4c89120c
--- /dev/null
+++ b/core/modules/image/tests/src/Kernel/ImageEffectsTest.php
@@ -0,0 +1,245 @@
+<?php
+
+namespace Drupal\Tests\image\Kernel;
+
+use Drupal\Core\Form\FormState;
+use Drupal\image\Entity\ImageStyle;
+use Drupal\image\Form\ImageEffectEditForm;
+use Drupal\Tests\Traits\Core\Image\ToolkitTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests image effects.
+ *
+ * @group image
+ */
+class ImageEffectsTest extends KernelTestBase {
+
+  use ToolkitTestTrait;
+
+  /**
+   * The image effect plugin manager service.
+   *
+   * @var \Drupal\image\ImageEffectManager
+   */
+  protected $imageEffectPluginManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'image',
+    'image_module_test',
+    'image_test',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->imageEffectPluginManager = $this->container->get('plugin.manager.image.effect');
+  }
+
+  /**
+   * Tests the 'image_resize' effect.
+   */
+  public function testResizeEffect() {
+    $this->assertImageEffect('image_resize', [
+      'width' => 1,
+      'height' => 2,
+    ]);
+    $this->assertToolkitOperationsCalled(['resize']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(1, $calls['resize'][0][0]);
+    $this->assertEquals(2, $calls['resize'][0][1]);
+  }
+
+  /**
+   * Tests the 'image_scale' effect.
+   */
+  public function testScaleEffect() {
+    // @todo Test also image upscaling in #3040887.
+    // @see https://www.drupal.org/project/drupal/issues/3040887
+    $this->assertImageEffect('image_scale', [
+      'width' => 10,
+      'height' => 10,
+    ]);
+    $this->assertToolkitOperationsCalled(['scale']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(10, $calls['scale'][0][0]);
+    $this->assertEquals(10, $calls['scale'][0][1]);
+  }
+
+  /**
+   * Tests the 'image_crop' effect.
+   */
+  public function testCropEffect() {
+    // @todo Test also keyword offsets in #3040887.
+    // @see https://www.drupal.org/project/drupal/issues/3040887
+    $this->assertImageEffect('image_crop', [
+      'anchor' => 'top-1',
+      'width' => 3,
+      'height' => 4,
+    ]);
+    $this->assertToolkitOperationsCalled(['crop']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(0, $calls['crop'][0][0]);
+    $this->assertEquals(1, $calls['crop'][0][1]);
+    $this->assertEquals(3, $calls['crop'][0][2]);
+    $this->assertEquals(4, $calls['crop'][0][3]);
+  }
+
+  /**
+   * Tests the 'image_convert' effect.
+   */
+  public function testConvertEffect() {
+    // Test jpeg.
+    $this->assertImageEffect('image_convert', [
+      'extension' => 'jpeg',
+    ]);
+    $this->assertToolkitOperationsCalled(['convert']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals('jpeg', $calls['convert'][0][0]);
+  }
+
+  /**
+   * Tests the 'image_scale_and_crop' effect.
+   */
+  public function testScaleAndCropEffect() {
+    $this->assertImageEffect('image_scale_and_crop', [
+      'width' => 5,
+      'height' => 10,
+    ]);
+    $this->assertToolkitOperationsCalled(['scale_and_crop']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(7.5, $calls['scale_and_crop'][0][0]);
+    $this->assertEquals(0, $calls['scale_and_crop'][0][1]);
+    $this->assertEquals(5, $calls['scale_and_crop'][0][2]);
+    $this->assertEquals(10, $calls['scale_and_crop'][0][3]);
+  }
+
+  /**
+   * Tests the 'image_scale_and_crop' effect with an anchor.
+   */
+  public function testScaleAndCropEffectWithAnchor() {
+    $this->assertImageEffect('image_scale_and_crop', [
+      'anchor' => 'top-1',
+      'width' => 5,
+      'height' => 10,
+    ]);
+    $this->assertToolkitOperationsCalled(['scale_and_crop']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(0, $calls['scale_and_crop'][0][0]);
+    $this->assertEquals(1, $calls['scale_and_crop'][0][1]);
+    $this->assertEquals(5, $calls['scale_and_crop'][0][2]);
+    $this->assertEquals(10, $calls['scale_and_crop'][0][3]);
+  }
+
+  /**
+   * Tests the 'image_desaturate' effect.
+   */
+  public function testDesaturateEffect() {
+    $this->assertImageEffect('image_desaturate', []);
+    $this->assertToolkitOperationsCalled(['desaturate']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEmpty($calls['desaturate'][0]);
+  }
+
+  /**
+   * Tests the image_rotate_effect() function.
+   */
+  public function testRotateEffect() {
+    // @todo Test also with 'random' === TRUE in #3040887.
+    // @see https://www.drupal.org/project/drupal/issues/3040887
+    $this->assertImageEffect('image_rotate', [
+      'degrees' => 90,
+      'bgcolor' => '#fff',
+    ]);
+    $this->assertToolkitOperationsCalled(['rotate']);
+
+    // Check the parameters.
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals(90, $calls['rotate'][0][0]);
+    $this->assertEquals('#fff', $calls['rotate'][0][1]);
+  }
+
+  /**
+   * Tests image effect caching.
+   */
+  public function testImageEffectsCaching() {
+    $state = $this->container->get('state');
+
+    // The 'image_module_test.counter' state variable value is incremented in
+    // image_module_test_image_effect_info_alter() every time the image effect
+    // plugin definitions are recomputed.
+    // @see image_module_test_image_effect_info_alter()
+    $state->set('image_module_test.counter', 0);
+
+    // First call should grab a fresh copy of the data.
+    $effects = $this->imageEffectPluginManager->getDefinitions();
+    $this->assertEquals(1, $state->get('image_module_test.counter'));
+
+    // Second call should come from cache.
+    $state->set('image_module_test.counter', 0);
+    $cached_effects = $this->imageEffectPluginManager->getDefinitions();
+    $this->assertEquals(0, $state->get('image_module_test.counter'));
+
+    // Check that cached effects are the same as the processed.
+    $this->assertSame($effects, $cached_effects);
+  }
+
+  /**
+   * Tests that validation errors are passed from the plugin to the parent form.
+   */
+  public function testEffectFormValidationErrors() {
+    $form_builder = $this->container->get('form_builder');
+
+    /** @var \Drupal\image\ImageStyleInterface $image_style */
+    $image_style = ImageStyle::create(['name' => 'foo']);
+    $effect_id = $image_style->addImageEffect(['id' => 'image_scale']);
+    $image_style->save();
+
+    $form = new ImageEffectEditForm();
+    $form_state = (new FormState())->setValues([
+      'data' => ['width' => '', 'height' => ''],
+    ]);
+    $form_builder->submitForm($form, $form_state, $image_style, $effect_id);
+
+    $errors = $form_state->getErrors();
+    $this->assertCount(1, $errors);
+    $error = reset($errors);
+    $this->assertEquals('Width and height can not both be blank.', $error);
+  }
+
+  /**
+   * Asserts the effect processing of an image effect plugin.
+   *
+   * @param string $effect_name
+   *   The name of the image effect to test.
+   * @param array $data
+   *   The data to be passed to the image effect.
+   */
+  protected function assertImageEffect($effect_name, array $data) {
+    $effect = $this->imageEffectPluginManager->createInstance($effect_name, ['data' => $data]);
+    $image = $this->getImage();
+    $this->imageTestReset();
+    $this->assertTrue($effect->applyEffect($image));
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalTests/Image/ToolkitTest.php b/core/tests/Drupal/FunctionalTests/Image/ToolkitTest.php
deleted file mode 100644
index 17c91158c2..0000000000
--- a/core/tests/Drupal/FunctionalTests/Image/ToolkitTest.php
+++ /dev/null
@@ -1,89 +0,0 @@
-<?php
-
-namespace Drupal\FunctionalTests\Image;
-
-/**
- * Tests image toolkit functions.
- *
- * @group Image
- */
-class ToolkitTest extends ToolkitTestBase {
-
-  /**
-   * Check that ImageToolkitManager::getAvailableToolkits() only returns
-   * available toolkits.
-   */
-  public function testGetAvailableToolkits() {
-    $manager = $this->container->get('image.toolkit.manager');
-    $toolkits = $manager->getAvailableToolkits();
-    $this->assertTrue(isset($toolkits['test']), 'The working toolkit was returned.');
-    $this->assertTrue(isset($toolkits['test:derived_toolkit']), 'The derived toolkit was returned.');
-    $this->assertFalse(isset($toolkits['broken']), 'The toolkit marked unavailable was not returned');
-    $this->assertToolkitOperationsCalled([]);
-  }
-
-  /**
-   * Tests Image's methods.
-   */
-  public function testLoad() {
-    $image = $this->getImage();
-    $this->assertTrue(is_object($image), 'Returned an object.');
-    $this->assertEqual($image->getToolkitId(), 'test', 'Image had toolkit set.');
-    $this->assertToolkitOperationsCalled(['parseFile']);
-  }
-
-  /**
-   * Test the image_save() function.
-   */
-  public function testSave() {
-    $this->assertFalse($this->image->save(), 'Function returned the expected value.');
-    $this->assertToolkitOperationsCalled(['save']);
-  }
-
-  /**
-   * Test the image_apply() function.
-   */
-  public function testApply() {
-    $data = ['p1' => 1, 'p2' => TRUE, 'p3' => 'text'];
-    $this->assertTrue($this->image->apply('my_operation', $data), 'Function returned the expected value.');
-
-    // Check that apply was called and with the correct parameters.
-    $this->assertToolkitOperationsCalled(['apply']);
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['apply'][0][0], 'my_operation', "'my_operation' was passed correctly as operation");
-    $this->assertEqual($calls['apply'][0][1]['p1'], 1, 'integer parameter p1 was passed correctly');
-    $this->assertEqual($calls['apply'][0][1]['p2'], TRUE, 'boolean parameter p2 was passed correctly');
-    $this->assertEqual($calls['apply'][0][1]['p3'], 'text', 'string parameter p3 was passed correctly');
-  }
-
-  /**
-   * Test the image_apply() function.
-   */
-  public function testApplyNoParameters() {
-    $this->assertTrue($this->image->apply('my_operation'), 'Function returned the expected value.');
-
-    // Check that apply was called and with the correct parameters.
-    $this->assertToolkitOperationsCalled(['apply']);
-    $calls = $this->imageTestGetAllCalls();
-    $this->assertEqual($calls['apply'][0][0], 'my_operation', "'my_operation' was passed correctly as operation");
-    $this->assertEqual($calls['apply'][0][1], [], 'passing no parameters was handled correctly');
-  }
-
-  /**
-   * Tests image toolkit operations inheritance by derivative toolkits.
-   */
-  public function testDerivative() {
-    $toolkit_manager = $this->container->get('image.toolkit.manager');
-    $operation_manager = $this->container->get('image.toolkit.operation.manager');
-
-    $toolkit = $toolkit_manager->createInstance('test:derived_toolkit');
-
-    // Load an overwritten and an inherited operation.
-    $blur = $operation_manager->getToolkitOperation($toolkit, 'blur');
-    $invert = $operation_manager->getToolkitOperation($toolkit, 'invert');
-
-    $this->assertIdentical('foo_derived', $blur->getPluginId(), "'Blur' operation overwritten by derivative.");
-    $this->assertIdentical('bar', $invert->getPluginId(), '"Invert" operation inherited from base plugin.');
-  }
-
-}
diff --git a/core/tests/Drupal/FunctionalTests/Image/ToolkitTestBase.php b/core/tests/Drupal/FunctionalTests/Image/ToolkitTestBase.php
index 4e2f5decbe..b05f1e9ea0 100644
--- a/core/tests/Drupal/FunctionalTests/Image/ToolkitTestBase.php
+++ b/core/tests/Drupal/FunctionalTests/Image/ToolkitTestBase.php
@@ -2,12 +2,22 @@
 
 namespace Drupal\FunctionalTests\Image;
 
+@trigger_error('The ' . __NAMESPACE__ . '\ToolkitTestBase class is deprecated in Drupal 8.8.0 and will be removed before Drupal 9.0.0. There is no replacement provided as functional test base class because toolkit operations should be tested as kernel tests. \Drupal\KernelTests\Core\Image\ToolkitTestTrait trait has been added to provide a similar functionality for toolkit kernel tests. See https://www.drupal.org/node/3035573.', E_USER_DEPRECATED);
+
 use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Tests\BrowserTestBase;
 use Drupal\Tests\TestFileCreationTrait;
 
 /**
  * Base class for image manipulation testing.
+ *
+ * @deprecated in drupal:8.8.0 and is removed from drupal:9.0.0. There is
+ *   no replacement provided as functional test base class because toolkit
+ *   operations should be tested as kernel tests. ToolkitTestTrait trait has
+ *   been added to provide a similar functionality for toolkit kernel tests.
+ *
+ * @see https://www.drupal.org/node/3035573
+ * @see \Drupal\Tests\Traits\Core\Image\ToolkitTestTrait
  */
 abstract class ToolkitTestBase extends BrowserTestBase {
 
diff --git a/core/tests/Drupal/KernelTests/Core/Image/ToolkitTest.php b/core/tests/Drupal/KernelTests/Core/Image/ToolkitTest.php
new file mode 100644
index 0000000000..eb641e2d17
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Image/ToolkitTest.php
@@ -0,0 +1,123 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Image;
+
+use Drupal\Core\Image\ImageInterface;
+use Drupal\Core\ImageToolkit\ImageToolkitInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\Traits\Core\Image\ToolkitTestTrait;
+
+/**
+ * Tests the image toolkit
+ *
+ * @group Image
+ */
+class ToolkitTest extends KernelTestBase {
+
+  use ToolkitTestTrait;
+
+  /**
+   * {@inheitdoc}
+   */
+  protected static $modules = [
+    'image_test',
+    'system',
+  ];
+
+  /**
+   * Testing image.
+   *
+   * @var \Drupal\Core\Image\ImageInterface
+   */
+  protected $image;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->image = $this->getImage();
+    $this->imageTestReset();
+  }
+
+  /**
+   * Tests that the toolkit manager only returns available toolkits.
+   */
+  public function testGetAvailableToolkits() {
+    $manager = $this->container->get('image.toolkit.manager');
+    $toolkits = $manager->getAvailableToolkits();
+
+    $this->assertArrayHasKey('test', $toolkits);
+    $this->assertArrayHasKey('test:derived_toolkit', $toolkits);
+    $this->assertArrayNotHasKey('broken', $toolkits);
+    $this->assertToolkitOperationsCalled([]);
+  }
+
+  /**
+   * Tests Image's methods.
+   */
+  public function testLoad() {
+    $image = $this->getImage();
+    $this->assertInstanceOf(ImageInterface::class, $image);
+    $this->assertEquals('test', $image->getToolkitId());
+    $this->assertToolkitOperationsCalled(['parseFile']);
+  }
+
+  /**
+   * Tests the Image::save() function.
+   */
+  public function testSave() {
+    $this->assertFalse($this->image->save());
+    $this->assertToolkitOperationsCalled(['save']);
+  }
+
+  /**
+   * Tests the 'apply' method.
+   */
+  public function testApply() {
+    $data = ['p1' => 1, 'p2' => TRUE, 'p3' => 'text'];
+    $this->assertTrue($this->image->apply('my_operation', $data));
+
+    // Check that apply was called and with the correct parameters.
+    $this->assertToolkitOperationsCalled(['apply']);
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals('my_operation', $calls['apply'][0][0]);
+    $this->assertEquals(1, $calls['apply'][0][1]['p1']);
+    $this->assertSame(TRUE, $calls['apply'][0][1]['p2']);
+    $this->assertEquals('text', $calls['apply'][0][1]['p3']);
+  }
+
+  /**
+   * Tests the 'apply' method without parameters.
+   */
+  public function testApplyNoParameters() {
+    $this->assertTrue($this->image->apply('my_operation'));
+
+    // Check that apply was called and with the correct parameters.
+    $this->assertToolkitOperationsCalled(['apply']);
+    $calls = $this->imageTestGetAllCalls();
+    $this->assertEquals('my_operation', $calls['apply'][0][0]);
+    $this->assertSame([], $calls['apply'][0][1]);
+  }
+
+  /**
+   * Tests image toolkit operations inheritance by derivative toolkits.
+   */
+  public function testDerivative() {
+    $toolkit_manager = $this->container->get('image.toolkit.manager');
+    $operation_manager = $this->container->get('image.toolkit.operation.manager');
+
+    $toolkit = $toolkit_manager->createInstance('test:derived_toolkit');
+    assert($toolkit instanceof ImageToolkitInterface);
+
+    // Load an overwritten and an inherited operation.
+    $blur = $operation_manager->getToolkitOperation($toolkit, 'blur');
+    $invert = $operation_manager->getToolkitOperation($toolkit, 'invert');
+
+    // 'Blur' operation overwritten by derivative.
+    $this->assertEquals('foo_derived', $blur->getPluginId());
+    // "Invert" operation inherited from base plugin.
+    $this->assertEquals('bar', $invert->getPluginId());
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Traits/Core/Image/ToolkitTestTrait.php b/core/tests/Drupal/Tests/Traits/Core/Image/ToolkitTestTrait.php
new file mode 100644
index 0000000000..21a6f4bcd0
--- /dev/null
+++ b/core/tests/Drupal/Tests/Traits/Core/Image/ToolkitTestTrait.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\Traits\Core\Image;
+
+use Drupal\Tests\TestFileCreationTrait;
+
+/**
+ * Provides common methods for image toolkit kernel tests.
+ *
+ * The testing class must ensure that image_test.module is enabled.
+ */
+trait ToolkitTestTrait {
+
+  use TestFileCreationTrait {
+    getTestFiles as drupalGetTestFiles;
+  }
+
+  /**
+   * Resets/initializes the history of calls to the test toolkit functions.
+   */
+  protected function imageTestReset() {
+    \Drupal::state()->delete('image_test.results');
+  }
+
+  /**
+   * Assert that all of the specified image toolkit operations were called once.
+   *
+   * @param string[] $expected
+   *   String array containing the operation names, e.g. load, save, crop, etc.
+   */
+  public function assertToolkitOperationsCalled(array $expected) {
+    // If one of the image operations is expected, 'apply' should be expected as
+    // well.
+    $operations = [
+      'resize',
+      'rotate',
+      'crop',
+      'desaturate',
+      'create_new',
+      'scale',
+      'scale_and_crop',
+      'my_operation',
+      'convert',
+    ];
+    if (count(array_intersect($expected, $operations)) > 0 && !in_array('apply', $expected)) {
+      $expected[] = 'apply';
+    }
+
+    // Determine which operations were called.
+    $actual = array_keys(array_filter($this->imageTestGetAllCalls()));
+
+    // Determine if there were any expected that were not called.
+    $uncalled = array_diff($expected, $actual);
+    $this->assertEmpty($uncalled);
+
+    // Determine if there were any unexpected calls. If all unexpected calls are
+    // operations and apply was expected, we do not count it as an error.
+    $unexpected = array_diff($actual, $expected);
+    $assert = !(count($unexpected) && (!in_array('apply', $expected) || count(array_intersect($unexpected, $operations)) !== count($unexpected)));
+    $this->assertTrue($assert);
+  }
+
+  /**
+   * Gets an array of calls to the 'test' toolkit.
+   *
+   * @return array
+   *   An array keyed by operation name ('parseFile', 'save', 'settings',
+   *   'resize', 'rotate', 'crop', 'desaturate') with values being arrays of
+   *   parameters passed to each call.
+   */
+  protected function imageTestGetAllCalls() {
+    return \Drupal::state()->get('image_test.results', []);
+  }
+
+  /**
+   * Sets up an image with the custom toolkit.
+   *
+   * @return \Drupal\Core\Image\ImageInterface
+   *   The image object.
+   */
+  protected function getImage() {
+    $image_factory = \Drupal::service('image.factory');
+    $file = current($this->drupalGetTestFiles('image'));
+    $image = $image_factory->get($file->uri, 'test');
+    $this->assertTrue($image->isValid());
+    return $image;
+  }
+
+}
