Skip navigation
All Places > Developer > Blog > 2015 > September
2015
In a previous post, we learned how to write JavaScript unit tests using Sugar 7's Jasmine framework.  Today we will learn how to write PHP unit tests using Sugar 7's PHPUnit framework.  If you do not have access to Sugar 7 unit-tests Github repository and you are a current SugarCRM customer or partner then you should request access.

 

This post will assume that you are already familiar with PHPUnit fundamentals.  If not, you can check out their Getting Started Guide.

 

Even if you do not have access or choose not to use the Sugar 7 PHPUnit framework, readers should still find the concepts covered in this post useful for testing Sugar 7 code.

 

Testing a Logic Hook

 

We will start with a realistic code customization so we can create a useful example of some unit tests.  There are a variety of server-side code customizations that are possible within Sugar 7 but perhaps the most common example would be the Logic Hook.  If you've done any significant amount of development on Sugar then you've like likely written more than one logic hook.  Sugar logic hooks have been an important tool in the Sugar Developer's toolbox since well before the release of Sugar 7.  So this is a very appropriate example for us to use.

 

Below you will see an example of a before_save logic hook for the Accounts module.  If you read the code, the use case is quite simple.  When an Account record is saved we check the current account type and if it is an Analyst account then we set the Account's industry to be Banking.  This is accomplished with only a handful of lines of code.

 

You can install this logic hook locally by copying AccountsOnSaveHooks.php to the custom/modules/Accounts directory and copying setIndustryOnSave.ext.php to custom/Extension/modules/Accounts/Ext/LogicHooks directory.

 

AccountsOnSaveHooks.php

<?php

/*
* Copyright 2015 SugarCRM Inc.
*/

/**
* Example of a module logic hook that we will unit test
*/
if (!defined('sugarEntry') || !sugarEntry) die('Not A Valid Entry Point');

class AccountsOnSaveHooks
{
function setIndustryForAnalystAccount($bean, $event, $arguments)
    {
//generic condition
if ($bean->account_type == 'Analyst')
        {
//update
$bean->industry = 'Banking';
        }
    }
}

setIndustryOnSave.ext.php
<?php
/*
* Copyright 2015 SugarCRM Inc.
*/
$hook_array['before_save'][] = Array(
//Processing index. For sorting the array.
1,

//Label. A string value to identify the hook.
'before_save example',

//The PHP file where your class is located.
'custom/modules/Accounts/AccountsOnSaveHooks.php',

//The class the method is in.
'AccountsOnSaveHooks',

//The method to call.
'setIndustryForAnalystAccount'
);

 

Creating our first PHPUnit test

 

If you follow the steps to deploy the Sugar 7 unit tests (for example, here are the steps for installing unit tests in Sugar 7.6), you will have a test/ directory that is added to your Sugar 7 installation that includes many test files.  You will also have PHPUnit installed under the vendor/ directory.

 



By convention, the tests/ directory mirrors the file structure of the rest of the Sugar application.

 

This means that when creating tests for our AccountsOnSaveHooks class, we will be creating them within a new file under the tests/custom/ directory.

 

To try our Sugar 7 PHPUnit test example, copy AccountsOnSaveHooksTest.php below to tests/custom/modules/Accounts/ directory.  This file contains a new AccountsOnSaveHooksTest class where we have implemented several unit tests.

 

AccountsOnSaveHooksTest.php

<?php
/*
* Copyright 2015 SugarCRM Inc.
*/

require_once 'custom/modules/Accounts/AccountsOnSaveHooks.php';

/**
*  Example tests for our custom Logic Hook.
*/
class AccountsOnSaveHooksTest extends Sugar_PHPUnit_Framework_TestCase
{

private $bean; //Accounts bean
private $hooks; //Hooks class

/**
     * Set up before each test
*/
public function setUp(){
parent::setUp();
$this->bean = BeanFactory::newBean('Accounts');
$this->hooks = new AccountsOnSaveHooks();
/**
         * Use SugarTestHelper to set up only those Sugar global values that are needed.
         * Framework will tear these down automatically after each test.
*/
SugarTestHelper::setUp("beanList");
    }

/**
     * Example that relies on SugarTestHelper
*/
public function testBeanListLoaded(){

global $beanList;
$this->assertNotEmpty($beanList["Accounts"], "This test relies on Accounts bean.");
    }

/**
     * Verify that logic hook changes Industry value when necessary
*/
public function testSetIndustryForAnalystAccount_ChangingIndustry()
    {
$bean = $this->bean;
$bean->account_type = "Analyst";
$this->assertNotEquals($bean->industry, "Banking");
//Mock event
$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
//Verify that Banking industry is now set
$this->assertEquals($bean->industry, "Banking");
$this->assertEquals($bean->account_type, "Analyst");

$bean->industry = "Apparel";

$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
//Verify that Banking industry is reset
$this->assertEquals($bean->industry, "Banking");
$this->assertEquals($bean->account_type, "Analyst");
    }


/**
     * Verify that Industry value is ignored by logic hook for other changes
*/
public function testSetIndustryForAnalystAccount_IgnoreIndustry()
    {
$bean = $this->bean;
$bean->account_type = "Reseller";
$this->assertNotEquals($bean->industry, "Banking");
//Mock event
$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
//Verify that Banking industry is ignored
$this->assertNotEquals($bean->industry, "Banking");
$this->assertEquals($bean->account_type, "Reseller");

$bean->industry = "Apparel";

//Mock event
$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
//Verify that industry is stays as Apparel
$this->assertEquals($bean->industry, "Apparel");
$this->assertEquals($bean->account_type, "Reseller");

    }


/**
     * Test that our logic hook can handle unexpected values
*/
public function testSetIndustryForAnalystAccount_UnexpectedValues()
    {
$bean = $this->bean;

//Expected values are not set on bean
unset($bean->industry);
unset($bean->account_type);

$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
$this->assertEmpty($bean->industry);
$this->assertEmpty($bean->account_type);


//Unexpected data type
$bean->account_type = -1;

$this->hooks->setIndustryForAnalystAccount($bean, "before_save", array());
$this->assertEmpty($bean->industry);
$this->assertEquals($bean->account_type, -1);

    }

}

 

You can run these tests by using the phpunit executable installed under the vendor/ directory.  You do need to make sure that tests/ is your current working directory when you run Sugar 7 PHPUnit tests otherwise it will not work

$ cd tests/

 

$ chmod +x ../vendor/bin/phpunit

 

$ ../vendor/bin/phpunit custom/modules/Accounts/AccountsOnSaveHooksTest.php

 

Your output from this command should look similar to this example below.

PHPUnit 4.1.4 by Sebastian Bergmann.

 

Configuration read from /Users/mmarum/Sites/SugarEnt-Full-7.6.0.0/tests/phpunit.xml.dist

 

....

 

Time: 341 ms, Memory: 53.25Mb

 

OK (4 tests, 15 assertions)

 

Some Sugar 7 PHPUnit framework features

 

There are a couple of features that we should call to your attention in the above example.

 

Sugar_PHPUnit_Framework_TestCase class

 

All of your Sugar 7 PHPUnit tests should extend the Sugar_PHPUnit_Framework_TestCase class.  This class provides a wrapper that ensures your test behaves consistently as well as enables the use of SugarTestHelper functions.

 

SugarTestHelper class

SugarTestHelper is the utility that you should use to mock out your necessary test dependencies.  For example, if you need to ensure that the $current_user is set to a valid value then you can use SugarTestHelper::setUp("current_user") to create a dummy user for the purposes of your test and ensure that it is removed after your test is complete.  Inspecting tests/SugarTestHelper.php will reveal all the different utilities available to you as you write your tests.

 

Advice for creating good Sugar 7 PHPUnit tests

 

For this example test, we could have tried to set it up so that we call $bean->save() in order to trigger the before_save logic hook indirectly.  This can seem like a reasonable approach because normally we would never call a logic hook function directly.  However, calling the save() function on a bean has other side effects.  For example, we could end up trigger multiple logic hooks when we are only interested in testing one for any given unit test.  We could also end up writing changes into the database that we would need to be cleaned up afterward.  Such DB writes will slow down the speed of your test execution considerably as your test suite grows.

Avoid causing DB transactions in your unit tests!
In this post by Jelle Vink, SugarCRM's Security Architect and resident Elasticsearch expert, offers an explanation of how the Sugar Job Scheduler and Job Queue affects Sugar 7's record indexing behavior.

 

Cron.php Execution

 

When cron.php is executed, there is a limit of how many jobs the driver executes and how long it will run. When either maximum is reached, the current cycle will terminate. The default maximums are 25 jobs and 1,800 seconds. Both can be changed in config_override.php:

$sugar_config['cron']['max_cron_jobs'] = 25;$sugar_config['cron']['max_cron_runtime'] = 1800;

 

There is also a minimum interval in minutes (which defaults to 1). If cron is executed multiple times in a row, it will only actually do something when the minimum interval is met. This can be changed to allow another cycle to be run again immediately after the previous finishes by using the following setting.

 $sugar_config['cron']['min_cron_interval'] = 0;

 



 

 

Elasticsearch Job Creation

 

There are a certain number of schedulers configured out of the box in Sugar 7. When cron is executed, the driver starts by executing schedulers that are due. These schedulers are not jobs themselves.  They simply create new jobs to be executed.  These jobs are then stored in job_queue table.

 

Once schedulers have created the necessary jobs, the driver starts executing the different jobs based on the order of creation, status, job delay and execution time.  For Elasticsearch there is one scheduler which is configured to run as often as possible - which means every time cron is executed. This scheduler will create a consumer job for every module for which there are queued Elasticsearch records in fts_queue table.

When a full reindex has been triggered by a Sugar Administrator, a consumer job for every FTS enabled module will be created and queued.

 

Always remember that your Elasticsearch jobs are not alone in the job queue.  There are other schedulers that create jobs like Email reminders, Database pruning, Check inbound email boxes, etc.  Jobs can also be created outside of schedulers via logic hooks or other custom code.

 

Job execution

 

As explained above, the cron driver will only run 25 jobs in the queue during each cycle. There is no guarantee that these are going to be Elasticsearch jobs.  Other jobs may also be waiting in the queue.  So there isn't any reason to give Elasticsearch jobs priority as we treat all jobs equally to guarantee that every job is executed eventually.

 

For Elasticsearch specific jobs there is also a maximum number of records that one Elasticsearch job will consume out of the queue for a given module. As explained above one Elasticsearch (consumer) job will only process one single module. The maximum of records an Elasticsearch consumer job will process for one module is by default 15,000. This can be configured using the following setting.

$sugar_config['search_engine']['max_bulk_query_threshold'] = 15000;

 

Effects on Elasticsearch indexing

 

In the demo data there is no single module which has a higher count of 15,000 records. The only limiting issue here is the amount jobs which are created which is in certain cases higher than the default 25. To get everything indexed for a full reindex, on average at least 2 cron runs are needed.

When testing Elasticsearch (full) reindexing after running cron, you should ensure that there are no records left in the fts_queue table. This is the only confirmation that all records are present in Elasticsearch.  A single cycle may not be enough to ensure all records have been indexed!

 

While it may cause an issue for Sugar Developers doing local development without cron setup, this is not an issue on a properly configured production system. For example, once a cron cycle stops after 25 jobs, the next cycle will happen soon - we typically recommend triggering cron every minute. That next run will pick up the next 25 jobs, etc, until indexing is complete.

 

Additional ElasticSearch fine tuning

 

The following config_override options are available for an admin to fine tune the performance of the indexing. This might change in the future as we are considering refactoring our queue out of the Sugar database. Below values are the defaults:

$sugar_config['search_engine']['max_bulk_query_threshold'] = 15000;$sugar_config['search_engine']['max_bulk_delete_threshold'] = 3000;$sugar_config['search_engine']['force_async_index'] = false;$sugar_config['search_engine']['max_bulk_threshold'] = 100;

 

Development / QA recommendations

We recommend adding the following to our deploy/automation to circumvent any issues regarding Elasticsearch (re)indexing and general cron usage.

 

All changes have to be done in config_override.php:

$sugar_config['cron']['max_cron_jobs'] = 500;$sugar_config['cron']['min_cron_interval'] = 0;

 

This will ensure that when a QA person or Sugar Developer executes cron.php multiple times in a short time frame, that cron will run immediately and will tend to clear the queue fully when there are a lot of jobs to be run.

This tutorial will cover the creation of new Jasmine unit tests for testing your Sugar 7 front end code.
In order to follow this tutorial, you will need access to the Sugar 7 Unit Test repository.  Make sure you have the latest code.  If you do not have access, then request access here.  You must be a current SugarCRM Customer or Partner.

 

The key concepts for testing your Sugar 7 JavaScript code will be the same no matter the framework in use.

 

Testing a Dashlet

 

In order for this to be a realistic example, we need to identify a Sidecar component that we want to test.  You could write your test against any part of the Sugar application including the out of the box Sugar 7 Sidecar components but lets take a moderately complex dashlet that we introduced in a previous blog post called Creating a Dashlet for Sugar 7 List Views.

 

The Case Counts by Status dashlet that was designed to be installed on Sugar 7 Contacts, Accounts, or Cases List Views.  It queries Sugar and shows a quick summary of the number of Cases in each status.

 



 

For convenience, here is a direct link to the Case Counts by Status controller code.

 

We will be writing Jasmine tests for this custom dashlet.  So follow the steps in Creating a Dashlet for Sugar 7 List Views to install the custom dashlet into your Sugar custom folder in order to follow along.  If you already have a dashlet or view you want to test instead then you will need to tweak the examples below to match.

 

Creating our first Jasmine test

 

For detailed documentation on writing Jasmine tests, please refer to the Jasmine documentation. In this section, we are going to assume you are familiar with some of the Jasmine basics, such as working with specs, expectations, and file structure.

 

If you follow the steps to deploy the Sugar 7 unit tests (for example, here are the steps for installing unit tests in Sugar 7.6), you will have a test/ directory that is added to your Sugar 7 installation that includes many test files.  You will have Grunt and Karma installed too.

 



By convention, the tests/ directory mirrors the file structure of the rest of the Sugar application.

 

This means that when creating tests for our Case Count by Status dashlet, we will be creating them within a new file under the tests/custom/ directory.

 

To try our basic Sidecar test example, create the file below at tests/custom/clients/base/views/case-count-by-status/case-count-by-status.js.

 

case-count-by-status.js

/*
* Basic Jasmine tests for Case Count by Status dashlet
*/
ddescribe("Case Count by Status", function () {
/*
     * Some useful constants for our tests.
     * We use them to keep track of the module, layout, and view we are testing
*/
var moduleName = 'Cases';
var viewName = 'case-count-by-status';
var layoutName = 'record-list';

/*
     * Variables shared by all tests
*/
var app;
var view;
var layout;

/**
     * Called before each test below.  We use this function to setup (or mock up) the necessary pieces
     * in order to test our Sidecar controller properly.
     *
     * Typically, we need to define Sugar view metadata and ensure that our controller JS file has been loaded
     * by the Sidecar framework.  We utilize some SugarTest utility functions to accomplish this.
*/
beforeEach(function() {
// Proxy for our typical Sidecar `app` object
        app = SugarTest.app;
//Ensure test metadata is initialized
SugarTest.testMetadata.init();
//Load custom Handlebars template (usually optional)
SugarTest.loadCustomHandlebarsTemplate(viewName, 'view', 'base');
//Load custom component JS (required)
SugarTest.loadCustomComponent('base', 'view', viewName);
//Mock view metadata for our custom view (usually required)
SugarTest.testMetadata.addViewDefinition(
            viewName,
            {
'panels': [
                    {
                        fields: []
                    }
                ]
            },
            moduleName
        );
//Commit custom metadata into Sidecar
SugarTest.testMetadata.set();

//Mock the Sidecar context object
var context = app.context.getContext();
context.set({
            module: moduleName,
            layout: layoutName
        });
context.prepare();

//Create parent layout for our view using fake context
        layout = app.view.createLayout({
            name: layoutName,
            context: context
        });

//Create our custom View before each test
        view = app.view.createView({
            name : viewName,
            context : context,
            module : moduleName,
            layout: layout,
            platform: 'base'
        });
    });

/**
     * Perform cleanup after each test.
*/
afterEach(function() {
//Delete test metadata
SugarTest.testMetadata.dispose();
//Delete list of declared components
app.view.reset();
//Dispose of our view
view.dispose();
    });

/**
     * Make sure that our view object exists
*/
it('should exist.', function() {
expect(view).toBeTruthy();
    });

/**
     * Make sure that when our controller creates expected HTML when render is called
*/
it('should render HTML', function(){
view.render();
expect(view.$el.html()).toContain("LBL_CASE_COUNT_BY_STATUS_TOTAL");
    });

/**
     * Tests for the _parseModels function.
*/
describe('_parseModels', function(){

it('should parse values based on available models', function(){

// Mock out the list of models (this normally comes from API)
var models = [
new Backbone.Model({id:'1', status:'Open'}),
new Backbone.Model({id:'2', status:'Closed'}),
new Backbone.Model({id:'3', status:'Open'})
            ];
// Call function with our mock list
view._parseModels(models, false);

//We had 2 open Cases in models array
expect(view.values['Open'].count).toBe(2);
//We had 1 closed Case in models array
expect(view.values['Closed'].count).toBe(1);
//Only 2 Statuses should exist in values array
expect(_.size(view.values)).toBe(2);

//Expect to find 3 Cases
expect(view.totalCases).toBe(3);
        });

it('should handle empty models array', function(){

// No models
var models = [];
view._parseModels(models, false);
expect(_.size(view.values)).toBe(0);
expect(view.totalCases).toBe(0);

        });

it('should handle unexpected values gracefully (null)', function(){

// null models array
var models = null;
view._parseModels(models, false);
expect(_.size(view.values)).toBe(0);
expect(view.totalCases).toBe(0);

         });

it('should handle unexpected values gracefully (object)', function(){

// object instead of array
var models = {};
view._parseModels(models, false);
expect(_.size(view.values)).toBe(0);
expect(view.totalCases).toBe(0);
        });

    });
});


 

You can then run these basic tests using the grunt karma:dev command from the root of your Sugar installation.  This should launch Chrome and after a few moments you will see output in your console similar to below.

mmarum$ grunt karma:dev

 

Running "karma:dev" (karma) task

 

WARN [watcher]: Pattern "/Users/mmarum/Sites/SugarEnt-Full-7.6.0.0/custom/modules/**/clients/**/*.hbs" does not match any file.

 

INFO [karma]: Karma v0.12.37 server started at http://localhost:9876/

 

INFO [launcher]: Starting browser Chrome

 

Chrome 45.0.2454 (Mac OS X 10.9.5) LOG: 'INFO[2015-9-7 20:8:16]: Router Started'

 

Chrome 45.0.2454 (Mac OS X 10.9.5): Executed 6 of 2791 (skipped 2785) SUCCESS (0.549 secs / 0.13 secs)

 

With Karma running in dev mode, every time you modify the test file then the unit tests will re-run interactively.  Right now, this unit test suite verifies that the dashlet parses new sets of models correctly.  However, we haven't written any tests that verifies what happens when you remove models.

As an exercise, try adding more tests to our example test suite that ensures that the Case Count by Status dashlet properly parses removal of models as well.

 

Anatomy of a Sugar 7 Jasmine Test Suite

 

You will notice that our test has some interesting features.

 

SugarTest helpers in beforeEach() function

 

The beforeEach() function contains several lines of code that will be similar for all your Sidecar Jasmine unit tests.  This is because it is used to setup your view's dependencies (like metadata, templates, context, and loaded JS code) in order to properly scaffold your custom View so that it can be created and tested properly.  In the main Sugar 7 application this plumbing is handled for you automatically.  But in order to properly isolate and unit test your JavaScript code, you need to set the plumping up manually using a variety of SugarTest helper functions.

In beforeEach(), many of the SugarTest helpers assume that you are only testing base Sugar 7 application files, so you will find specific utilities for working with JavaScript files and Handlebars templates located under custom/ folder within custom-component-helper.js.

 

Cleanup in afterEach() function

 

It is important to remember to cleanup after ourselves after each test.  This is necessary in order to ensure that our tests don't interact with each other during execution and to prevent memory leaks that could harm performance of your test run.

In afterEach(), you should clear out custom metadata that you've used as well as dispose of any views or layouts you created during your test.

 

ddescribe() versus describe() functions

 

By default, the Karma test runner will run all the available unit tests.  This can take a couple minutes as there are thousands of tests to run that are part of our base Sugar 7 test suite.  So adding an extra 'd' in front of our describe() function at the top of file is a clue to Karma to run these tests exclusively as a convenience.  This blog post explains more about how to use exclusive tests.  Just remember to change function name back to describe() when you are ready to commit your test into the full test suite.

 

Assertions are within it() functions

 

The actual testing happens within Jasmine's it() functions.  We only have a handful of assertions where we use Jasmine's expect() function in this example.  But clearly, you can add as many assertions as you want once you have your Sidecar view setup properly.

 

Jasmine Test Template

 

As a convenience, here is a handy template for creating your own Jasmine tests for Sugar 7 views.  Just look for the TODOs within and update those sections appropriately based on the view you are testing.  The trickiest part of writing tests is to making sure your view's dependencies are setup appropriately in the beforeEach() function.  But once that's complete, you'll find creating actual Jasmine specs a snap!

 

test-template.js

/*
* Basic Jasmine test template for any Sugar 7 view
*/
ddescribe("Jasmine template for Sugar 7 views", function () {
/*
     * Some useful constants for our tests.
     * We use them to keep track of the module, layout, and view we are testing
*/
var moduleName = 'Accounts';    //TODO CHANGE TO AN APPROPRIATE MODULE
var viewName = "CHANGE_ME";     //TODO CHANGE TO YOUR VIEW NAME
var layoutName = "record-list"; //TODO CHANGE TO YOUR PARENT LAYOUT NAME

/*
     * Variables shared by all tests
*/
var app;
var view;
var layout;

/**
     * Called before each test below.  We use this function to setup (or mock up) the necessary pieces
     * in order to test our Sidecar controller properly.
     *
     * Typically, we need to define Sugar view metadata and ensure that our controller JS file has been loaded
     * by the Sidecar framework.  We utilize some SugarTest utility functions to accomplish this.
*/
beforeEach(function() {
// Proxy for our typical Sidecar `app` object
        app = SugarTest.app;
//Ensure test metadata is initialized
SugarTest.testMetadata.init();

/**
         * TODO LOAD ANY ADDITIONAL DEPENDENCIES USING SugarTest.load FUNCTIONS HERE
*/

//Load custom Handlebars template
SugarTest.loadCustomHandlebarsTemplate(viewName, 'view', 'base' /*, moduleName */);
//Load custom component JS
SugarTest.loadCustomComponent('base', 'view', viewName /*, moduleName */);


//Mock view metadata for our custom view
SugarTest.testMetadata.addViewDefinition(
            viewName,
//TODO SETUP YOUR FAKE VIEW METADATA HERE
            {
'panels': [
                    {
                        fields: []
                    }
                ]
            },
            moduleName
        );
//Commit custom metadata into Sidecar
SugarTest.testMetadata.set();

//Mock the Sidecar context object
var context = app.context.getContext();
context.set({
            module: moduleName,
            layout: layoutName
        });
context.prepare();

//Create parent layout for our view using fake context
        layout = app.view.createLayout({
            name: layoutName,
            context: context
        });

//Create our View before each test

        view = app.view.createView({
            name : viewName,
            context : context,
            module : moduleName,
            layout: layout,
            platform: 'base'
        });

    });

/**
     * Perform cleanup after each test.
*/
afterEach(function() {
//Delete test metadata
SugarTest.testMetadata.dispose();
//Delete list of declared components
app.view.reset();
//Dispose of our view
view.dispose();
    });


/**
     * Make sure that our view object exists
*/
it('should exist.', function() {
expect(view).toBeTruthy();
    });

/**
     * TODO ADD YOUR TESTS HERE WITHIN DESCRIBE() AND IT() FUNCTIONS
*/

});