Problem/Motivation

SimpleTest's TestBase is one of the most important classes in Drupal's quality chain, but it has very little unit test coverage.

Writing PHPUnit-based tests for TestBase can be challenging because of global dependencies in many methods.

Proposed resolution

Write PHPUnit-based tests and refactor in unobtrusive ways to facilitate testing.

Remaining tasks

User interface changes

API changes

Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Unfrozen changes Unfrozen because it improves automated testing and makes SimpleTest more maintainable.

Comments

mile23’s picture

Status: Active » Needs review
StatusFileSize
new25.59 KB

Here's a patch.

Changes to TestBase:

- Added an instance method called t(), based on https://drupal.org/node/2079611 in order to remove a global dependency. Instead of calling t() we call $this->t(). This issue #1601146: Allow custom assertion messages using predefined placeholders talks about removing t() entirely or somehow changing the error reporting method, so obviously my version might already be obsolete.

- Not all instances of t() in the class can be replaced with $this->t(). Specifically within insertAssert() which is a static method, and so therefore can't call our instance method.

- Refactored database access out of assert(), into a new method called storeAssertion(). This allows us to capture the behavior of assert() in tests while avoiding the database access.

- require_once() at the top of the file. We can't use DRUPAL_ROOT without also including bootstrap.inc, which is the opposite of how unit testing it supposed to work. :-)

- Ignored testing of methods with lots of global dependencies.

- CRAP analysis: ~14000 to ~7000 after adding these tests. Still pretty high, but better.

dawehner’s picture

Awesome work!

  1. +++ b/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php
    @@ -231,6 +253,23 @@ protected function checkRequirements() {
    +   * @see assert()
    

    I think this should be better like self::assert or static::assert

  2. +++ b/core/modules/simpletest/lib/Drupal/simpletest/TestBase.php
    @@ -397,7 +433,7 @@ protected function getAssertionCall() {
    -    return _drupal_get_last_caller($backtrace);
    +    return \_drupal_get_last_caller($backtrace);
    

    Are you sure that this change is needed?

mile23’s picture

StatusFileSize
new24.53 KB
new2.27 KB

Done. :-)

The last submitted patch, 3: 2144669_3_testbase_unit_test.patch, failed testing.

mile23’s picture

The last submitted patch, 3: 2144669_3_testbase_unit_test.patch, failed testing.

dawehner’s picture

nitesh sethia’s picture

Assigned: Unassigned » nitesh sethia
nitesh sethia’s picture

StatusFileSize
new26.1 KB

Rerolling of the patch is done.. :)

Status: Needs review » Needs work

The last submitted patch, 9: 2144669_9_testbase_unit_test.patch, failed testing.

The last submitted patch, 9: 2144669_9_testbase_unit_test.patch, failed testing.

nitesh sethia’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 9: 2144669_9_testbase_unit_test.patch, failed testing.

nitesh sethia’s picture

StatusFileSize
new26.63 KB

Patch has been removed. I have removed the Php syntax issue.

nitesh sethia’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 14: 2144669_13_testbase_unit_test.patch, failed testing.

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new15.93 KB

It's a re-roll.

Simpletest tests pass fine locally, let's see what the testbot says.

mile23’s picture

StatusFileSize
new15.55 KB
new1.43 KB

Yay testbot is happy, but we need to remove some references to t().

mile23’s picture

Issue summary: View changes

Added beta evaluation to summary.

mile23’s picture

Assigned: nitesh sethia » Unassigned
daffie’s picture

Status: Needs review » Needs work

I did a review and I have some problems with the patch:

  1. Can we rename the randomStringValidateProvider to providerRandomStringValidate.
  2. Can the documentation for the provider return values move from the test to the provider.
  3. Can the expected value be the first parameter. As we do with other tests.
  4. For the function testRandomStringValidate: can we remove the documentation line "Tests the random strings validation rules". We have @covers for that. The same for "@see \Drupal\simpletest\TestBase::randomStringValidate()".
  5. For the function: testRandomString. Remove the documentation lines: "Tests that the random string contains a non-alphanumeric character." and "@see \Drupal\simpletest\TestBase::randomString()." We have @covers for that.
  6. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -80,4 +81,387 @@ public function testRandomString() {
    +   * Test checkRequirements().
    

    Use @covers. Also for other functions

  7. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -80,4 +81,387 @@ public function testRandomString() {
    +  public function providerTestAssert() {
    

    This is the provider for the function assert(). So shouldn't we call it providerAssert(). Also for other functions.

  8. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -80,4 +81,387 @@ public function testRandomString() {
    +  /**
    +   * Data provider for assertTrue().
    +   */
    +  public function providerTestAssertTrue() {
    

    Document the return array. Also for other functions.

  9. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -80,4 +81,387 @@ public function testRandomString() {
    +  public function testError($group) {
    +    $test_base = $this->getTestBaseForAssertionTests();
    +    $this->assertEquals(
    +        FALSE,
    +        $this->invokeProtectedMethod($test_base, 'error', array('msg', $group))
    +    );
    +  }
    

    The only thing that is being tested is the function returns a value of FALSE.

  10. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -80,4 +81,387 @@ public function testRandomString() {
    +  public function testCopyConfig() {
    +    $mock_storage_source = $this->getMock('Drupal\Core\Config\StorageInterface');
    +    $mock_storage_source->expects($this->any())
    +      ->method('deleteAll');
    +    $mock_storage_source->expects($this->any())
    +      ->method('listAll')
    +      ->will($this->returnValue(array('name')));
    +    $mock_storage_target = clone $mock_storage_source;
    +    $mock_storage_source->expects($this->any())
    +      ->method('read')
    +      ->will($this->returnValue(array('thing')));
    +    $mock_storage_target->expects($this->any())
    +      ->method('write');
    +
    +    $test_base = $this->getTestBaseForAssertionTests();
    +    $this->assertNull($test_base->copyConfig($mock_storage_source, $mock_storage_target));
    +  }
    

    Can we make two distinct mocks. The cloning results in mocks that have functions that they do not use. It is also a bit confusing for me.

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new20.68 KB
new13.92 KB

7. providerTestAssert() only gets used as a dataprovider for testAssert().

9 and 10. Both tests are pretty weak, so I updated them.

What I did:

  • Refactored setUp() away.
  • Expanded testError() and testCopyConfig().
  • Changed docblocks throughout to have correct @covers.
  • All the changes in #21 except 7.
daffie’s picture

If I read the best practices from #2057905: [policy, no patch] Discuss the standards for phpunit based tests, I get the idea that it is testAssert() and providerAssert(). I know that we have done a lot of providerTestAssert() constructions, so I thought I should mention it. But if I am wrong please let me know.

mile23’s picture

StatusFileSize
new19.34 KB
new6.42 KB

Not wrong at all.

daffie’s picture

Status: Needs review » Needs work

Thank you for updating your patch. I have reviewed it again.

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +17,479 @@
+  public function getTestBaseForAssertionTests($test_id = 'test_id') {
...
+  public function invokeProtectedMethod($object, $method_name, array $arguments) {

Can we move these functions to the top of the class. They are helper functions.

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +17,479 @@
+  /**
+   * Data provider for testAssertEqual().
+   */
+  public function providerAssertIdentical() {

Data provider for testAssertIdentical

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +17,479 @@
+  public function testError($status, $group) {
+    // Mock up a TestBase object.
+    $mock_test_base = $this->getMockBuilder('Drupal\simpletest\TestBase')
+      ->setMethods(array('assert'))
+      ->getMockForAbstractClass();
+
+    // Set expectations for assert().
+    $mock_test_base->expects($this->once())
+      ->method('assert')
+      // The first argument to assert() should be the expected $status.
+      ->with($status)
+      // Arbitrary return value.
+      ->willReturn("$status:$group");
+
+    // Invoke error().
+    $this->assertEquals(
+        "$status:$group",
+        $this->invokeProtectedMethod($mock_test_base, 'error', array('msg', $group))
+    );
+  }

Can you explain this to me. The function error returns the assert function. And it is the assert function that you mock. So now you are testing if willReturn("$status:$group") is equal to "$status:$group". Can we concentrate on how the group is 'User notice' is treated differently.

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +17,479 @@
+  public function testRandomObject() {

Can we add a provider and test for null, 1, 2, 4 and 7. Also for randomObject and randomString. Sorry, should have mentioned it in the previous review. :-(

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +17,479 @@
+    $mock_target_storage->expects($this->never())
+      ->method('listAll');
...
+    $mock_target_storage->expects($this->never())
+      ->method('read');
...
+    $mock_source_storage->expects($this->never())
+      ->method('deleteAll');
...
+    $mock_source_storage->expects($this->never())
+      ->method('write');

Just curious: why add these?

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new19.9 KB
new6.25 KB

Can we move these functions to the top of the class. They are helper functions.

Generally, we add things after existing code to minimize the reroll in other patches. It would make more sense to have them at the top, though.

Can you explain this to me. The function error returns the assert function. And it is the assert function that you mock. So now you are testing if willReturn("$status:$group") is equal to "$status:$group". Can we concentrate on how the group is 'User notice' is treated differently.

We don't care what assert() returns. The real test of whether we got the behavior around 'User notices is in this expectation:

      // The first argument to assert() should be the expected $status.
      ->with($status)

Combined with our data provider:

      array('debug', 'User notice'),
      array('exception', 'Not User notice'),

...we get an expectation that when $group is 'User notices,' we'll get the 'debug' $status, and 'exception' otherwise.

[In testCopyConfig()] Just curious: why add these?

copyConfig() takes two StorageInterface objects as arguments. We want to make sure the code distinguishes between them correctly. So we set up an expectation to that effect. If someone were editing copyConfig(), it would be easy to mix them up, so we do what we can to help that person not make a mistake. That's the reason for making unit tests in the first place. :-)

daffie’s picture

Status: Needs review » Needs work

Thank you for explaining the testError() function to me. I was wrong and somehow I looked cross eyed when I reviewed it.
For the testCopyConfig(): your explanation is also valid. I would not have added them. But I can also see your point of view.
It all looks good to me.
I have only one question and one nitpick left:

/**
 * @file
 * Contains \Drupal\simpletest\TestBaseTest.
 */

Should it not be: Contains \Drupal\Tests\simpletest\Unit\TestBaseTest.

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +16,501 @@
+    $test_id = 'test_id';
+    $test_base = $this->getTestBaseForAssertionTests($test_id);

Can we rewrite this to: $test_base = $this->getTestBaseForAssertionTests();

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new20.31 KB
new7.65 KB

For the @file block: Good catch.

For $test_id... No, I want to be specific, because later on we say this:

    $this->assertEquals($test_id, $assertion['test_id']);

In fact, getTestBaseForAssertionTests() shouldn't have a default value for $test_id.

This patch:

  • Changes "Contains..." annotation
  • Removes the default value from getTestBaseForAssertionTests().
  • Changes testAssert() to compute the test id based on assertion status.
daffie’s picture

Status: Needs review » Reviewed & tested by the community

It all looks good to me.
All comments are in order.
The patch fixed the problem described in the issue summary.
The patch gets a RTBC from me.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 28: 2144669_28.patch, failed testing.

daffie queued 28: 2144669_28.patch for re-testing.

daffie’s picture

Status: Needs work » Reviewed & tested by the community

Back to RTBC

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 28: 2144669_28.patch, failed testing.

daffie queued 28: 2144669_28.patch for re-testing.

daffie’s picture

Status: Needs work » Reviewed & tested by the community

Back to RTBC

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/modules/simpletest/src/TestBase.php
    @@ -1231,7 +1247,6 @@ private function restoreEnvironment() {
       public function errorHandler($severity, $message, $file = NULL, $line = NULL) {
         if ($severity & error_reporting()) {
    -      require_once DRUPAL_ROOT . '/core/includes/errors.inc';
    
    @@ -1266,7 +1281,6 @@ public function errorHandler($severity, $message, $file = NULL, $line = NULL) {
       protected function exceptionHandler($exception) {
    -    require_once DRUPAL_ROOT . '/core/includes/errors.inc';
         $backtrace = $exception->getTrace();
    

    This is fine now that we don't have a unit test class that extends TestBase

  2. +++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
    @@ -16,68 +16,504 @@
    +  /**
    +   * Provides data for testCopyConfig().
    +   *
    +   * @return array
    +   *   - A keyed array which maps a key to a nested array.
    +   */
    +  public function providerCopyConfig() {
    +    return [
    +      [[]],
    +      [['key' => ['value']]],
    +      [['key' => ['value'], 'another_key' => ['another_value']]],
    +    ];
    +  }
    +
    +  /**
    +   * @covers ::copyConfig
    +   * @dataProvider providerCopyConfig
    +   */
    +  public function testCopyConfig($source_map) {
    +    // Mock the target storage.
    +    $mock_target_storage = $this->getMockBuilder('Drupal\Core\Config\StorageInterface')
    +      ->setMethods(array('deleteAll', 'listAll', 'write', 'read'))
    +      ->getMockForAbstractClass();
    +    // Set method expectations. We always want deleteAll(), never want
    +    // listAll(), never want read(), and expect the count of source storage for
    +    // write().
    +    $mock_target_storage->expects($this->once())
    +      ->method('deleteAll');
    +    $mock_target_storage->expects($this->never())
    +      ->method('listAll');
    +    $mock_target_storage->expects($this->exactly(count($source_map)))
    +      ->method('write');
    +    $mock_target_storage->expects($this->never())
    +      ->method('read');
    +
    +    // Mock source storage.
    +    $mock_source_storage = $this->getMockBuilder('Drupal\Core\Config\StorageInterface')
    +      ->setMethods(array('deleteAll', 'listAll', 'write', 'read'))
    +      ->getMockForAbstractClass();
    +    // Set method expectations. These are backwards from $mock_target_storage.
    +    // We never want deleteAll(), always want listAll(), never want write(),
    +    // and expect the count of source storage for read().
    +    $mock_source_storage->expects($this->never())
    +      ->method('deleteAll');
    +    $mock_source_storage->expects($this->once())
    +      ->method('listAll')
    +      // listAll() should return the keys of $source_map.
    +      ->willReturnCallback(
    +        function () use ($source_map) {
    +          return array_keys($source_map);
    +        }
    +      );
    +    $mock_source_storage->expects($this->never())
    +      ->method('write');
    +    $mock_source_storage->expects($this->exactly(count($source_map)))
    +      ->method('read')
    +      // read() should map a key to a value from $source_map.
    +      ->willReturnCallback(
    +        function ($key) use ($source_map) {
    +          return $source_map[$key];
    +        }
    +      );
     
    -    // Ensure that we can generate random strings with a length of 1.
    -    $string = $this->stub->randomString(1);
    -    $this->assertEquals(1, strlen($string));
    +    // Exercise copyConfig(). Assert NULL because copyConfig() is void and only
    +    // has side-effects.
    +    $test_base = $this->getTestBaseForAssertionTests('test_id');
    +    $this->assertNull($test_base->copyConfig($mock_source_storage, $mock_target_storage));
       }
    

    Let's not add this test - it just ties us to the implementation making copy_config hard to change for no benefit.

    In fact this is a general criticism of all of the tests - we are tying down internal implementation details making changing harder than necessary.

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new17.76 KB
new2.91 KB

Thanks for the review.

Let's not add this test - it just ties us to the implementation making copy_config hard to change for no benefit.

I will remove it, but being able to re-run this analysis is kind of the point of the test. This patch didn't need a reroll despite being a month old. The code it tests is basically set in stone, and any unintentional change to that code should set off alarms. Also, it's only the behavior of the testbot we're dealing with. :-)

In fact this is a general criticism of all of the tests - we are tying down internal implementation details making changing harder than necessary.

Tying down *internal* details, but mocking *external* ones. This means you can just change the test. :-)

With a functional test like SimpleTest, one is rightly scared to change the test. With an isolated unit test, you have a baseline and you can see if what you're changing breaks the test in the way you predict that it will break, without having to run a SimpleTest that takes ten years and then you still have to reverse-engineer the result. You can also read the diff of the test to find out what expectations changed when it comes time to review a patch.

I think if you give this style a chance, you'll see it's benefit.

I'd suggest keeping it, as per patch #28, but you're the maintainer. :-)

daffie’s picture

Status: Needs review » Reviewed & tested by the community

@alexpott wants testCoptConfig() and providerCopyConfig() removed from the patch and that is done. So back to RTBC.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +16,435 @@
+  /**
+   * Data provider for testAssertEqual().
+   */
+  public function providerAssertEqual() {
+    return array(
+      array(TRUE, 0, 0),
+      array(FALSE, 'foof', 'yay'),
+    );
+  }
...
+  /**
+   * Data provider for testAssertIdentical and testAssertNotIdentical().
+   */
+  public function providerAssertIdentical() {
+    return array(
+      array(TRUE, 0, 0),
+      array(FALSE, 'foof', 'yay'),
+    );
+  }

Let's add to these so that the difference between assertEqual and assertIdentical is exposed and tested.

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new18.8 KB
new4.69 KB

OK. So there is now only one data provider for testAssertIdentical(), testAssertEqual(), and their *Not brethren. It covers more tricky comparison issues.

Status: Needs review » Needs work

The last submitted patch, 40: 2144669_40.patch, failed testing.

mile23’s picture

Hello, MigrateFileTest! Thanks for ruining my day! :-) #2417549: Drupal\migrate_drupal\Tests\d6\MigrateFileTest fail in MigrateTestBase

Mile23 queued 40: 2144669_40.patch for re-testing.

mile23’s picture

Status: Needs work » Needs review

Passes now.

daffie’s picture

Status: Needs review » Needs work

It all good good to me with one exception:

+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +16,459 @@
+   * @dataProvider providerAssertIdentical

Should this test not use providerAssertIdentical as its dataprovider?

mile23’s picture

Status: Needs work » Needs review
+++ b/core/modules/simpletest/tests/src/Unit/TestBaseTest.php
@@ -16,68 +16,459 @@
+  /**
+   * @covers ::assertFalse
+   * @dataProvider providerAssertTrue
+   */
+  public function testAssertFalse($expected, $value) {
+    $test_base = $this->getTestBaseForAssertionTests('test_id');
+    $this->assertEquals(
+        (!$expected),
+        $this->invokeProtectedMethod($test_base, 'assertFalse', array($value))
+    );
+  }
+

You mean this? $expected gets flipped with !.

daffie’s picture

Status: Needs review » Needs work

@Mile23: My typo mistake! No, I mean that it should be: "@dataprovider providerEqualityAssertions".

mile23’s picture

Status: Needs work » Needs review
StatusFileSize
new18.81 KB
new689 bytes

I see what you mean... Is this what you had in mind?

daffie’s picture

Status: Needs review » Reviewed & tested by the community

@Mile23: Yes, that is what I had in mind.

It all looks good to me.
All documentation is in order.
This is about tests so as a beta change it is allowed.
It get a RTBC from me.

alexpott’s picture

+++ b/core/modules/simpletest/src/TestBase.php
@@ -1334,7 +1350,6 @@ private function restoreEnvironment() {
-      require_once DRUPAL_ROOT . '/core/includes/errors.inc';

@@ -1369,7 +1384,6 @@ public function errorHandler($severity, $message, $file = NULL, $line = NULL) {
-    require_once DRUPAL_ROOT . '/core/includes/errors.inc';

Are you sure about this? Like do you know why this is here?

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Lol I answered this myself in #36 :)

Committed a954b05 and pushed to 8.0.x. Thanks!

Thank you for adding the beta evaluation to the issue summary.

  • alexpott committed 921317d on 8.0.x
    Issue #2144669 by Mile23, Nitesh Sethia: Improve/Refactor TestBase...

  • alexpott committed 88350da on 8.0.x
    Revert "Issue #2144669 by Mile23, Nitesh Sethia: Improve/Refactor...
alexpott’s picture

Status: Fixed » Needs work
PHP Notice:  Undefined variable: x in /Volumes/devdisk/dev/drupal/core/modules/simpletest/tests/src/Unit/TestBaseTest.php on line 321

After committing this patch.

daffie’s picture

Status: Needs work » Needs review
StatusFileSize
new18.73 KB
new436 bytes
mile23’s picture

I guess it's reasonable to remove that data set. Funny that it never gave me a problem.

I'd RTBC but it's my patch you just modified.

daffie’s picture

Status: Needs review » Reviewed & tested by the community

Mile23 agrees with my change so back to RTBC.

Thanks for your review Mile23.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 9305a47 and pushed to 8.0.x. Thanks!

  • alexpott committed 9305a47 on 8.0.x
    Issue #2144669 by Mile23, daffie, Nitesh Sethia: Improve/Refactor...

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.