
Upgrade Your Drupal Skills
We trained 1,000+ Drupal Developers over the last decade.
See Advanced Courses NAH, I know EnoughMore testing with Codeception and Drupal projects
This is a bit of a follow-up to Mike Bell's introductory article on using Codeception to create Drupal test suites. He concludes by stating he "need[s] to figure out a way of creating a Codeception module which allows you to plug in a Drupal testing user (ideally multiple so you can test each role) and then all the you have to do is call a function which executes the above steps to confirm your logged in before testing authenticated behaviour."
"Something along the lines of:
$I->drupalLogin('editor');
So, after skimming through Codeception and Mink documentation, I've tinkered with two potential ways of achieving this... for acceptance testing at least.
A crude toolbox
The first method is to use two custom classes to provide details of (a) a general Drupal site and (b) the specific site to be tested. This idea stemmed from this article which suggests that including literals - such as account credentials, paths and even form labels - in tests is bad practice. What if the login button label changes? etc.
Anyway, this is currently set up as follows. In the tests/_helpers directory, we include a new file providing an abstract class, DrupalSite:
-
abstract class DrupalSite {
-
// Site structure: login and registration.
-
public $loginPage = 'user/login';
-
public $usernameField = 'Username';
-
public $passwordField = 'Password';
-
public $loginSubmitField = 'edit-submit';
-
// Site data: user accounts.
-
public $adminUsername;
-
public $adminPassword;
-
}
It contains some defaults (the usual path to the login page, the default labels for Username & Password fields and the Login submit button) and two member variables to hold a test admin user's credentials. Then, in order to provide some values specific to the site we're testing, we extend that class to provide some of the missing information:
-
class MySite extends DrupalSite {
-
// Site data: user accounts.
-
public $adminUsername = 'admin';
-
public $adminPassword = 'test';
-
}
Assuming we have such a system in place (it's proving useful in other areas already, such as managing HTTP authentication on testing and staging environments and dealing with Drupal's clean URLs) then we can also use the DrupalSite class to provide Drupal- or site-specific routines for, eg, logging in:
-
abstract class DrupalSite {
-
// Site structure: meta data.
-
...
-
// Site structure: login and registration.
-
public $loginPage = 'user/login';
-
public $usernameField = 'Username';
-
public $passwordField = 'Password';
-
public $loginSubmitField = 'edit-submit';
-
// Site data: user accounts.
-
public $adminUsername;
-
public $adminPassword;
-
public $testUsername;
-
public $testPassword;
-
/**
-
* Acceptance helper to log in an (admin) user.
-
*/
-
public function logInAsAdminUser($I) {
-
$this->logIn($I, $this->adminUsername, $this->adminPassword);
-
}
-
/**
-
* Acceptance helper to log in a test user.
-
*/
-
public function logInAsTestUser($I) {
-
$this->logIn($I, $this->testUsername, $this->testPassword);
-
}
-
/**
-
* Acceptance helper to log in a user with given credentials.
-
*
-
* @param $I
-
* @param $username
-
* @param $password
-
*/
-
protected function logIn($I, $username, $password) {
-
$I->amOnPage($this->getSiteUrl($this->loginPage));
-
$I->see('User account');
-
$I->see('Enter your [$site_name] username.');
-
$I->amGoingTo('fill and submit the login form');
-
$I->fillField($this->usernameField, $username);
-
$I->fillField($this->passwordField, $password);
-
$I->click($this->loginSubmitField);
-
$I->expect('to be logged in');
-
// @todo You'll probably have a much better way of verifying
-
// whether we've successfully logged in.
-
$I->see('My account');
-
$I->see('Log out');
-
}
-
}
Of course we must set the site-specific user credentials in MySite.php. We can also override the method in the subclass to provide an alternative method of logging in, if the site provides alternate or customised login methods (such as a single sign-on implementation). In addition, we can benefit from building on these classes to provide, for example, a better structure for managing site roles and corresponding test user accounts or other helper functionality such as passing HTTP authentication or managing clean URLs for paths used in tests.
To actually put this into practice and use it in a test, we must first include the subclass in the acceptance suite's _bootstrap.php:
require_once 'tests/_helpers/MySite.php';
then instantiate an object of the MySite class in our test:
-
$I = new WebGuy($scenario);
-
$S = new MySite($I);
-
$I->wantTo('log in as an admin');
-
$S->logInAsAdminUser($I);
-
// Verify login steps...
Using WebHelper
The second method is perhaps more in line with Mike's idea of using Codeception's helpers to build new methods into the WebGuy object $I:
$I->loginToDrupal('editor');
Codeception achieves this by "emulat[ing] multiple inheritance for Guy classes (CodeGuy, TestGuy, WebGuy, etc)". Custom actions "can be defined in helper classes", Basically, the Guy classes have their methods defined in modules. They don't actually truly contain any of them, but act as a proxy for them. Codeception provides modules to emulate web requests, access data, interact with popular PHP libraries, etc. On top of this, we can provide additional methods using the corresponding Helper class to the relevant Guy class.
To 'enable' all of the gathered Guy methods, you use the build command. It generates the definition of the Guy class by copying the signatures from the configured modules:
$ codecept.phar build
For more about this, see the Codeception guide to Modules and Helpers.
Phew. With all that in mind, we can dive into editing the empty WebHelper class Codeception provides. We have to dig a little deeper to implement this: trying to use the test 'sub-routine' idea from above (i.e. implementing the login procedure as a series of $I scenario steps) doesn't really fit, but we can kludge it like so:
-
<?php
-
namespace Codeception\Module;
-
// here you can define custom functions for WebGuy
-
class WebHelper extends \Codeception\Module {
-
/**
-
* Helper function to log in WebGuy with given credentials. We pass in
-
* in $I, resulting in a call like:
-
*
-
* $I->loginToDrupal($I, $name, $pass);
-
*
-
* This is horrible, and I'm probably missing something.
-
*
-
* @param $I
-
* @param $name
-
* @param $pass
-
*/
-
function loginToDrupal($I, $name, $pass) {
-
$I->amOnPage('user/login');
-
$I->see('User account');
-
$I->see('Enter your [$site_name] username.');
-
$I->amGoingTo('fill and submit the login form');
-
$I->fillField('name', $name);
-
$I->fillField('pass', $pass);
-
$I->click('Log in');
-
$I->expect('to be logged in');
-
// @todo Provide the verification steps after successfully
-
// being logged in here.
-
$I->see(...);
-
}
-
}
but boy, does that seem nasty. If we're going down this route, it would be best left to using custom classes as above.
Shminky pinky (Chris Waddle)
Codeception by default uses the phpBrowser module for acceptance tests, and Mink to control it. The Mink Acceptance Testing documentation was a great place to start looking deeper - and of course with any OO frameworks the Session class API documentation also proved useful.
So, we can directly manipulate the browser session using Mink from within what will eventually be our new WebGuy method. I ended up with something like this:
-
<?php
-
namespace Codeception\Module;
-
// here you can define custom functions for WebGuy
-
class WebHelper extends \Codeception\Module {
-
// Frist attempt at a custom login function...
-
public function login() {
-
$username = 'admin';
-
$password = 'test';
-
$session = $this->getModule('PhpBrowser')->session;
-
$login_url = $session->getCurrentUrl() . '/user/login';
-
$session->visit($login_url);
-
// Fail the test step if we cannot access the login page.
-
$this->assertTrue(
-
$session->getStatusCode() == 200,
-
'could not access login page'
-
);
-
// Get login form elements from $page.
-
$page =$session->getPage();
-
$loginForm = $page->findById('user-login');
-
$usernameField = $loginForm->findField('edit-name');
-
$passwordField = $loginForm->findField('edit-pass');
-
$submitButton = $loginForm->findButton('edit-submit');
-
// Enter credentials and submit the form.
-
$usernameField->setValue($username);
-
$passwordField->setValue($password);
-
$submitButton->click();
-
}
-
}
and the corresponding test:
-
$I = new WebGuy($scenario);
-
$I->wantTo('log in as an admin');
-
$I->login();
-
// Verify log in, if necessary.
-
// Continue additional test steps.
Of course we can be a bit cleverer in passing in the test user's role and/or account credentials by using our custom class MySite - getting the best of both worlds. We use the custom classes to provide information about the structure of the site we're testing and WebHelper to add a 'proper' new method for WebGuy objects. Note there are site 'component' literals including in the login() method, such as the ids for the form elements and the path to the login page, but hey - WIP etc.
Caveat - login sessions and database refreshes
At this point I noticed some of my tests started failing. I realised that, in running multiple tests, subsequent session visits we're already logged in, resulting in a 403 HTTP code being returned when visiting the user login page. As you might have noticed in the code above, there is a slightly crappy assertTrue statement to check the page response is a 200. It's not, so the test fails. So our log in/session issue here is is mostly down to checks in the login method that could be improved somewhat.
Anyway, we might get away with it - tests should ideally be run on a clean, stable version of the site database and be cleaned-up or refreshed before any test is ran. One test should never affect another - it's likely that some of our tests will write to the database (testing creating a new node, creating a user etc.) so we should really use the Db module's cleanup configuration option. To set up database refreshes, do the following:
- place a clean SQL dump of the site's database in tests/_data/ using, eg,
drush sql-dump --result-file=/path/to/suite/tests/_data/project_db_clean.sql - edit the acceptance.suite.yml file to include the
Dbmodule and add configuration for your MySQL server:
-
# Codeception Test Suite Configuration
-
# RUN `build` COMMAND AFTER ADDING/REMOVING MODULES.
-
class_name: WebGuy
-
modules:
-
enabled:
-
- PhpBrowser
-
- WebHelper
-
- Db
-
config:
-
PhpBrowser:
-
Db:
-
dsn: 'mysql:host=localhost;dbname=project_db'
-
user: 'db_user'
-
password: 'db_pass'
-
dump: tests/_data/project_db_clean.sql
-
populate: false
-
cleanup: true
switching in appropriate values for dsn, user and password. Also ensure that the dump option points to the correct path within your test suite where the SQL dump is stored. Read more about this in the Cleaning Up section of the Codeception Acceptance Tests documentation.
So, which method shall we use for a login procedure?
Custom classes
- With this method, the login procedure still effectively runs as a 'sub routine' of a test, i.e. it can (and does) contain
wantTo,expect,seeor otherWebGuymethod calls. - We can build on these classes to provide the roles (from stories or Drupal roles) and test user credentials for each.
- Can be overridden in MySite.php if a site uses a alternate or customised login method.
Using WebHelper
- No longer a test or 'subroutine' of a test, but effectively now a single step in a test scenario.
- No longer site-specific.
- Nicer integration with Codeception's framework.
- Nicer syntax, e.g. $I->login('admin')
By introducing a new method to the WebGuy class, we effectively condense the login procedure into one, atomic test or scenario step. We can of course precede and follow this one step with wantTo, amGoingTo and see steps in our tests themselves. The step can also fail 'internally' and thus fail the calling test (for example if the session cannot access the login page).
However, we should realise that we have also removed the finer-grained steps of the original 'can log in' test. So, perhaps we should always use the WebHelper method providing we include a single test dedicated solely to testing the individual steps to login. Technically this could be a standard test or a 'subroutine' test as described in A crude toolbox above. However, the subroutine loses its value if we're to only call it once.
With that in mind, two of our tests might end up looking something like this:
AdminCanLoginCept.php - full
-
$I = new WebGuy($scenario);
-
// Used here for site structure/user credentials:
-
$S = new MySite($I);
-
$username = 'admin';
-
$password = 'test';
-
$I->wantTo('log in as an admin');
-
$I->amOnPage($S->loginPage);
-
$I->see('User account');
-
$I->see('Enter your [$site_name] username.');
-
$I->amGoingTo('fill and submit the login form');
-
$I->fillField($S->usernameField, $username);
-
$I->fillField($S->passwordField, $password);
-
$I->click($S->loginSubmitField);
-
$I->expect('to be logged in');
-
// Verify login steps...
AdminCanLoginCept.php - optionally using custom classes and 'subroutine'
-
$I = new WebGuy($scenario);
-
$S = new MySite($I);
-
$I->wantTo('log in as an admin');
-
$S->logInAsAdminUser($I);
-
// Verify login steps...
AdminCanPostArticle.php (and all other tests requiring login)
-
$I = new WebGuy($scenario);
-
$I->want to('post an article');
-
$I->amGoingTo('login as an admin');
-
$I->login('admin');
-
$I->amGoingTo('post an article');
-
$I->amOnPage('node/add/article');
-
$I->fillField(...);
-
...
Where to go from here?
This brain-fart only really involves acceptance testing and of course has been delivered from an addled brain who has only just started looking into testing - and Codeception in particular. Once we've got some acceptance suites under our belts, the most sensible place to start looking next would be functional tests - for which we can provide Framework Helpers:
-
<?php
-
namespace Codeception\Module;
-
class DrupalHelper extends \Codeception\Util\Framework {
-
public function _initialize() {
-
$this->client = new \Codeception\Util\Connector\Universal();
-
// or any other connector you implement
-
// we need specify path to index file
-
$this->client->setIndex('index.php');
-
}
-
}
Following that? A fully-blown Drupal module as part of the Codeception framework? Codeception suggests that "if you have written a module that may be useful to others, share it. Fork the Codeception repository, put the module into the src/Codeception/Module directory, and send a pull request."
Back at you, Mike ;)
Addendum: This article was written on my Nexus 7 - it was only when coming to post it here that I realised just how overdue some loving is for my site... I also had a bit of a re-write when a network/sync issue on my tablet (and the subsequent accessing of the article via the web interface at evernote.com) led to a loss of most the latter half...
About Drupal Sun
Drupal Sun is an Evolving Web project. It allows you to:
- Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
- Facet based on tags, author, or feed
- Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
- View the entire article text inline, or in the context of the site where it was created
See the blog post at Evolving Web