3

I have a helper class in my module that facilitates in creating Drupal user accounts based on some information returned from an external system.

What I would like to do is add tests for this class.

Here is the class in question:

<?php

namespace Drupal\netforum_auth\Netforum;

use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\user\UserInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Component\Utility\Unicode;
use Drupal\user\Entity\User;

/**
 * Class NetforumAccountManager.
 *
 * @package Drupal\netforum_auth\Netforum
 */
class NetforumAccountManager implements NetforumAccountManagerInterface {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The language manager.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $languageManager;

  /**
   * The logger.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $logger;

  /**
   * The Netforum client.
   *
   * @var \Drupal\netforum_auth\Netforum\NetforumClientInterface
   */
  protected $client;

  /**
   * Class constructor.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, LoggerChannelFactoryInterface $logger_channel_factory, NetforumClientInterface $client) {
    $this->entityTypeManager = $entity_type_manager;
    $this->languageManager = $language_manager;
    $this->logger = $logger_channel_factory;
    $this->client = $client;
  }

  /**
   * {@inheritdoc}
   */
  public function createUser(\stdClass $netforum_account) : int {
    $user = $this->entityTypeManager->getStorage('user')->create();
    $langcode = $this->languageManager->getCurrentLanguage()->getId();
    $email_address = $netforum_account->result->Customer->cst_eml_address_dn;
    $customer_key = $netforum_account->result->Customer->cst_key;
    $customer_number = $netforum_account->result->Customer->cst_recno;

    $parts = explode('@', $email_address);
    $drupal_username = $parts[0] . $customer_number;
    $password = user_password(15);

    $user->enforceIsNew();
    $user->setUsername($drupal_username);
    $user->setPassword($password);
    $user->setEmail($email_address);
    $user->set("init", $email_address);
    $user->set("langcode", $langcode);
    $user->set("preferred_langcode", $langcode);
    $user->set("preferred_admin_langcode", $langcode);
    $user->set("preferred_admin_langcode", $langcode);
    $user->set("field_netforum_customer_key", $customer_key);
    $user->set("field_netforum_customer_recno", $customer_number);
    $user->activate();
    $user->save();

    // Set the roles.
    $account = User::load($user->id());
    $this->updateUserRoles($account, $netforum_account);

    return $user->id();
  }

  /**
   * {@inheritdoc}
   */
  public function updateUser(UserInterface $account, \stdClass $netforum_account) {
    // @todo: This will be filled in later if they do any profile related stuff.

    // Update the roles.
    $this->updateUserRoles($account, $netforum_account);
  }

  /**
   * Wipe and rebuild a users roles to be in-sync with Netforum memberships.
   */
  public function updateUserRoles(UserInterface $account, $netforum_account) {
    $netforum_role = $netforum_account->result->Individual->ind_int_code;
    $membership_info = $this->getActiveMembershipsForCustomer($netforum_account);
    $member_types = $this->getActiveMemberTypesFromCustomerMembership($membership_info);

    // Remove previous roles, for security reasons.
    $previous_roles = $account->getRoles(TRUE);

    foreach ($previous_roles as $role) {
      // Don't remove Administrator if it was previously set.
      if ($role !== 'administrator') {
        $account->removeRole($role);
      }
    }

    $roles = [];

    // Staff is an actual user role, not a membership type
    if ($netforum_role == 'Staff') {
      $roles[] = 'content_editor';
    }

    foreach ($member_types as $role_name) {
      $roles[] = $this->mapToDrupalRole($role_name);
    }

    // At the least, we should assign a boilerplate role.
    if (empty($roles)) {
      $account->addRole('netforum_member');
    }

    foreach ($roles as $role) {
      $account->addRole($role);
    }

    $account->save();
  }

  /**
   * Maps netforum membership types to local Drupal roles.
   */
  public function mapToDrupalRole(string $membership_type) : string {
    switch (Unicode::strtoupper($membership_type)) {
      case 'CARRIER DIVISION':
        $role = 'carrier_division';
        break;

      case 'RAIL DIVISION':
        $role = 'rail_division';
        break;

      case 'SUPPLIER DIVISION':
        $role = 'supplier_division';
        break;

      case 'ASSOCIATE':
        $role = 'associate';
        break;

      default:
        $role = 'netforum_member';
        break;
    }

    return $role;
  }

  /**
   * Returns all membership information for a given netforum user.
   *
   * I really dislike this call because it returns just a string of XML instead
   * of the responses we are used to receiving. It is a custom SQL call done by
   * using ExecuteMethod, a unique API method in Netforum.
   */
  public function getActiveMembershipsForCustomer(\stdClass $netforum_account) {
    $customer_key = $netforum_account->result->Customer->cst_key;

    $arguments = [
      'serviceName' => 'GetActiveMembershipsForCustomer',
      'methodName' => 'GetActiveMemberships',
      'parameters' => [
        'Parameter' => [
          'Name' => 'cst_key',
          'Value' => $customer_key,
        ],
      ],
    ];

    $response = $this->client->request('ExecuteMethod', [$arguments]);

    $parts = explode('-', $customer_key);
    $last_segment = end($parts);

    if ($response->error) {
      $message = $response->result->Message . ' Customer key ending in: @key.';
      $this->logger->get('netforum_auth')->error($message, ['@key' => $last_segment]);
    }
    elseif (!$response->result) {
      $message = 'Customer key ending in: @key has no membership information.';
      $this->logger->get('netforum_auth')->info($message, ['@key' => $last_segment]);
    }
    else {
      // This entire result is returned as an xml string, convert it.
      $xml = simplexml_load_string($response->result->any);
      $json = json_encode($xml);
      $response->result = json_decode($json, TRUE);
    }

    return $response;
  }

  /**
   * Filters out active roles from the membership information.
   */
  public function getActiveMemberTypesFromCustomerMembership($membership_information) : array {
    $roles = [];

    if ($membership_information->result) {
      foreach ($membership_information->result['Membership'] as $membership) {
        if ($membership['MbrStatus'] == 'Active') {
          $roles[] = $membership['MbrType'];
        }
      }
    }

    return $roles;
  }

}

How can I approach testing this class? My first thought was I needed to mock the method that makes an API call and have it return an array like the real call would do (passed to getActiveMemberTypesFromCustomerMembership).

I can't quite figure out though how to do this in code. Here is where I started:

<?php

namespace Drupal\Tests\netforum_auth\Kernel;

use Drupal\netforum_auth\Netforum\NetforumAccountManager;
use Drupal\Tests\token\Kernel\KernelTestBase;
use org\bovigo\vfs\vfsStream;
use org\bovigo\vfs\visitor\vfsStreamStructureVisitor;

/**
 * Class UserCreateTest
 *
 * @package Drupal\Tests\netforum_auth\Kernel
 * @group netforum_auth
 */
class UserCreateTest extends KernelTestBase {

  /**
   * Modules to enable.
   *
   * @var array
   */
  public static $modules = ['system', 'user', 'netforum_auth'];

  protected function setUp() {
    parent::setUp();

    // create roles
  }

  public function testUserIsCreated() {
    $netforum_wsdl_response = new \stdClass();
    $netforum_wsdl_response->result = new \stdClass();
    $netforum_wsdl_response->result->Customer = new \stdClass();
    $netforum_wsdl_response->result->Customer->cst_eml_address_dn = 'zztesting@example.comzz';
    $netforum_wsdl_response->result->Customer->cst_key = '123-456-789';
    $netforum_wsdl_response->result->Customer->cst_recno = '123456';

    $result = new \stdClass();
    $result->response = [
      'Membership' => [
        0 => [
          'MbrStatus' => 'Active',
          'MbrType' => 'Associate'
        ]
      ]
    ];

    $prophecy = $this->prophesize(NetforumAccountManager::CLASS);
    $prophecy->getActiveMembershipsForCustomer($netforum_wsdl_response)->willReturn($result);
    $netforum_account_manager = $prophecy->reveal();

    $uid = $netforum_account_manager->createUser($netforum_wsdl_response);
    // check $uid is a User
    // check role(s)
  }

}

But I get this error:

Method call:
  - createUser(stdClass:00000000584be36000000000303910bf Object (
    'result' => stdClass:00000000584be19c00000000303910bf Object (
        'Customer' => stdClass:00000000584be05a00000000303910bf Object (
            'cst_eml_address_dn' => 'zztesting@example.comzz'
            'cst_key' => '123-456-789'
            'cst_recno' => '123456'
        )
    )
))
on Double\Drupal\iana_netforum_auth\Netforum\NetforumAccountManager\P1 was not expected, expected calls were:
  - getActiveMembershipsForCustomer(exact(stdClass:00000000584be36000000000303910bf Object (
    'result' => stdClass:00000000584be19c00000000303910bf Object (
        'Customer' => stdClass:00000000584be05a00000000303910bf Object (
            'cst_eml_address_dn' => 'zztesting@example.comzz'
            'cst_key' => '123-456-789'
            'cst_recno' => '123456'
        )
    )
)))

This is the first time I have tried this. Should the two get calls be moved out to a new class, and mock the one method? Then test the actual class here?

1

Well, I wound up refactoring the class to decouple the dependency on the $client service and moved the API call in this class to an external class. I did not need any mocks and from there just tested my class with dummy data. The class I am testing only assumes now that a Drupal user object or response from a Netforum call is passed in, or both, making it testable (or, more easy to test) than before.

<?php

namespace Drupal\Tests\netforum_auth\Kernel;

use Drupal\Tests\token\Kernel\KernelTestBase;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
use Drupal\user\Entity\User;
use Drupal\simpletest\UserCreationTrait;

/**
 * Class NetformAccountManagerTest.
 *
 * @package Drupal\Tests\netforum_auth\Kernel
 * @group netforum_auth
 */
class NetformAccountManagerTest extends KernelTestBase {

  use UserCreationTrait {
    createRole as drupalCreateRole;
  }

  /**
   * Modules to enable.
   *
   * @var array
   */
  public static $modules = ['system', 'user', 'field', 'text', 'netforum_auth'];

  /**
   * The NetforumAccountManager service.
   *
   * @var \Drupal\netforum_auth\Netforum\NetforumAccountManager
   */
  protected $accountManager;

  /**
   * Holds a simluated NetforumClient response about an individual in Netforum.
   *
   * @var \stdClass
   */
  protected $dummy_account;

  /**
   * {@inheritdoc}
   */
  protected function setUp() {
    parent::setUp();

    $this->installSchema('system', 'sequences');
    $this->installEntitySchema('user');

    $this->drupalCreateRole([], 'associate', 'Associate');
    $this->drupalCreateRole([], 'member', 'Member');
    $this->drupalCreateRole([], 'content_editor', 'Content Editor');

    $field = FieldStorageConfig::create(array(
      'field_name' => 'field_netforum_customer_key',
      'entity_type' => 'user',
      'type' => 'text',
    ));

    $field->save();

    $field = FieldStorageConfig::create(array(
      'field_name' => 'field_netforum_customer_recno',
      'entity_type' => 'user',
      'type' => 'text',
    ));

    $field->save();

    FieldConfig::create([
      'field_name' => 'field_netforum_customer_key',
      'entity_type' => 'user',
      'label' => 'Customer Key',
      'bundle' => 'user',
    ])->save();

    FieldConfig::create([
      'field_name' => 'field_netforum_customer_recno',
      'entity_type' => 'user',
      'label' => 'Customer Number',
      'bundle' => 'user',
    ])->save();

    $this->accountManager = $this->container->get('netforum_auth.netforum_account_manager');

    $netforum_wsdl_response = new \stdClass();
    $netforum_wsdl_response->result = new \stdClass();
    $netforum_wsdl_response->result->Customer = new \stdClass();
    $netforum_wsdl_response->result->Individual = new \stdClass();
    $netforum_wsdl_response->result->Individual->ind_int_code = 'Staff';
    $netforum_wsdl_response->result->Customer->cst_eml_address_dn = 'zztesting@example.comzz';
    $netforum_wsdl_response->result->Customer->cst_key = '123-456-789';
    $netforum_wsdl_response->result->Customer->cst_recno = '123456';

    $netforum_wsdl_response->result->Membership = [
      0 => [
        'MbrStatus' => 'Active',
        'MbrType' => 'Associate',
      ],
      1 => [
        'MbrStatus' => 'Inactive',
        'MbrType' => 'At-Large Motor Carrier Division',
      ],
      2 => [
        'MbrStatus' => '',
        'MbrType' => 'At-Large Rail Division',
      ],
      3 => [
        'MbrStatus' => 'Active',
        'MbrType' => 'Member',
      ],
    ];

    $this->dummy_account = $netforum_wsdl_response;
  }

  /**
   * Test that a user is created with the correct roles.
   *
   * @covers \Drupal\netforum_auth\Netforum\NetforumAccountManager::createUser
   */
  public function testUserIsCreated() {
    $uid = $this->accountManager->createUser($this->dummy_account);
    $this->assertNotEquals(0, $uid);

    $account = User::load($uid);
    $roles = $account->getRoles(TRUE);

    // Our Drupal user should have 3 roles, not 2.
    $this->assertContains('associate', $roles);
    $this->assertContains('member', $roles);
    $this->assertContains('content_editor', $roles);
    $this->assertNotContains('administrator', $roles);
  }

  /**
   * Tests the user role mapping method.
   *
   * Unrecognized roles should be set as netforum_member.
   *
   * @covers \Drupal\netforum_auth\Netforum\NetforumAccountManager::mapToDrupalRole
   */
  public function testUserRoleMapping() {
    $role = $this->accountManager->mapToDrupalRole('associate');
    $this->assertEquals('associate', $role);

    $role = $this->accountManager->mapToDrupalRole('MEMBER');
    $this->assertEquals('member', $role);

    $role = $this->accountManager->mapToDrupalRole('AT-LARGE MoToR cARRIER DIVISIOn');
    $this->assertEquals('at_large_motor_division', $role);

    $role = $this->accountManager->mapToDrupalRole('DUMMY TYPE');
    $this->assertEquals('netforum_member', $role);

    $role = $this->accountManager->mapToDrupalRole('Staff');
    $this->assertNotEquals('administrator', $role);
  }

  /**
   * Tests that we get the correct types that we expect.
   *
   * @covers \Drupal\netforum_auth\Netforum\NetforumAccountManager::getMembershipTypes
   */
  public function testMembershipTypeParser() {
    $types = $this->accountManager->getMembershipTypes($this->dummy_account);
    $this->assertEquals($types, ['Associate', 'Member']);
  }

}

I'm not sure if this is 100% great approach, but my class and its couple of methods are now covered by tests. To supplement this test, I also want to write a behavior test with Behat to simulate a user actually logging in - the results should be exactly the same.

Would love to know more though about how others are testing their own services or classes.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.