-2

I am new to unit testing. Started working on unit test using PHPUnit. But it seems to be taking too much time. If consider I take 3 hours to write a Class, its taking my 7 hours to write test case for it. There are a few factors behind it.

  1. As I told, I am new to this stuff, so have to do lot of R&D.
  2. Sometimes I get confused what to test in it.
  3. Mocking some function takes time.
  4. There are lots of permutation and combination in a big function, so it gets difficult and pretty time consuming to mock those functions.

Any idea how do I write test cases in faster way? Any ideas for actual code so it gets faster to write test cases?

What are the best practices I should follow in my code below?

<?php namespace Api\Core;

use Api\Exceptions\APICoreException;
use Api\Exceptions\APITransformationException;
use Api\Exceptions\APIValidationException;
use CrmValidation;
use Component;
use DB;
use App\Traits\Api\SaveTrait;
use App\Traits\Api\FileTrait;
use App\Repositories\Contract\MigrationInterface;
use App\Repositories\Contract\ClientFeedbackInterface;
use Mockery\CountValidator\Exception;
use Api\Libraries\ApiResponse;
use App\Repositories\Contract\FileInterface;
use App\Repositories\Contract\MasterInterface;
use App\Traits\Api\ApiDataConversionTrait;
use ClientFeedback;
use MigrationMapping;
use Migration;
use ComponentDetail;
use FavouriteEditorCore;

/**
 * Class ClientFeedbackCore
 *
 * @package Api\Core
 */
class ClientFeedbackCore
{
    use SaveTrait, FileTrait, ApiDataConversionTrait;

    /**
     * @var array
     */
    private $request = [];

    /**
     * @var
     */
    private $migrationFlag;

    /**
     * @var string
     */
    private $table = 'client_feedback';

    /**
     * @var MigrationInterface
     */
    public $migrationRepo;

    /**
     * @var ClientFeedbackInterface
     */
    public $clientFeedbackRepo;

    /**
     * @var MasterInterface
     */
    public $masterRepo;

    /**
     * @var FileInterface
     */
    public $fileRepo;


    /**
     * ClientFeedbackCore constructor.
     *
     * @param MigrationInterface      $migrationInterface
     * @param ClientFeedbackInterface $clientFeedbackInterface
     * @param MasterInterface         $masterInterface
     * @param FileInterface           $fileInterface
     */
    public function __construct(
        MigrationInterface $migrationInterface,
        ClientFeedbackInterface $clientFeedbackInterface,
        MasterInterface $masterInterface,
        FileInterface $fileInterface
    ) {

        $this->clientFeedbackRepo = $clientFeedbackInterface;
        $this->migrationRepo = $migrationInterface;
        $this->masterRepo = $masterInterface;
        $this->fileRepo = $fileInterface;

    }

    /**
     * @author pratik.joshi
     */
    public function init()
    {
        $this->migrationFlag = getMigrationStatus($this->table);
    }

    /**
     * @param $request
     * @return array
     * @author pratik.joshi
     * @desc stores passed data into respective entities and then stores into migration tables. If any issue while insert/update exception is thrown.
     */
    public function store($request)
    {
        if ($request == null || empty($request))
        {
            throw new APIValidationException(trans('messages.exception.validation',['reason'=> 'request param is not provided']));
        }

        $clientFeedbackId = $migrationClientFeedbackId = $favouriteEditorId = null;
        $errorMsgWhileSave = null;
        $clientFeedback = [];

        $filesSaved = [];
        $categoryNamesForFiles = [];

        $operation = config('constants.op_type.INSERT');
        $this->init();

        if(
            keyExistsAndissetAndNotEmpty('id',$request)
            && CrmValidation::getRowCount($this->table, 'id', $request['id'])
        ) {
            $operation = config('constants.op_type.UPDATE');
        }

        //Step 1: set up data based on the operation
        $this->request = $this->convertData($request,$operation);

        //Step 2: Save data into repo, Not using facade as we cant reuse it, every facade will repeat insert update function
        if ($operation == config('constants.op_type.INSERT'))
        {
            $clientFeedback = $this->insertOrUpdateData($this->request, $this->clientFeedbackRepo);
        }
        else if($operation == config('constants.op_type.UPDATE'))
        {
            $clientFeedback = $this->insertOrUpdateData($this->request, $this->clientFeedbackRepo,$this->request['id']);
        }


        if ( !keyExistsAndissetAndNotEmpty('client_feedback_id',$clientFeedback[ 'data' ]) )
        {
            throw new APICoreException(trans('messages.exception.data_not_saved'));
        }

        //If no exception thrown, save id
        $clientFeedbackId = $clientFeedback[ 'data' ][ 'client_feedback_id' ];

        //Step 3: prepare array for mig repo & save()
        if($this->migrationFlag && $operation == config('constants.op_type.INSERT'))
        {
            $this->saveMigrationDataElseThrowException($this->table, $clientFeedback[ 'data' ][ 'client_feedback_id' ], 'client_feedback', $this->request['name']);
        }
        //If no exception thrown, save id
        $paramsForFileSave = [
            'entity_id'   => $clientFeedbackId,
            'entity_type' => $this->clientFeedbackRepo->getModelName(),
        ];

        //Step 4: Save datainto file, Save job feedback files with params : files array to save, migration data for files
        //The method prepareFileData will be called by passing multiple files, and some needed params for file which internally calls prepareData
        //$filePreparedData will be in format : $filePreparedData['field_cf_not_acceptable_four'][0] => whole file array(modified)
        $filePreparedData = $this->fileRepo->prepareFileData($this->request[ 'files' ],  $this->masterRepo, $paramsForFileSave);
        $filesSaved = $this->fileRepo->filesInsertOrUpdate($filePreparedData);
        //If any file is not saved, it returns false, throw exception here
        if($filesSaved == false)
        {
            throw new APICoreException(trans('messages.exception.data_not_saved'));
        }

        //Step 5: Save data for file in migra repo.
        //For each file type and each file in it, loop, Check for insert data
        if(getMigrationStatus('file') && array_key_exists('insert',$filesSaved) && count($filesSaved['insert']))
        {
            foreach ($filesSaved['insert'] as $singleFileSaved)
            {
                $fileId = $singleFileSaved['data']['file_id'];
                $wbTitle = $filesSaved['extra'][$fileId];
                $this->saveMigrationDataElseThrowException('file', $singleFileSaved['data']['file_id'], 'files', $wbTitle);
            }
        }

        //We get created by or last modified by
        $createdOrLastModifiedBy = keyExistsAndissetAndNotEmpty('created_by',$this->request) ? $this->request['created_by'] : $this->request['last_modified_by'];
        //Calling FavouriteEditorCore as we want to save favorite or un-favorite editor
        $favouriteEditor = FavouriteEditorCore::core(
                            $this->request[ 'component_id' ],
                            $this->request[ 'rating' ],
                            $this->request[ 'wb_user_id' ], $createdOrLastModifiedBy,
                            $this->request[ 'same_editor_worker' ]
        );

        if ( !issetAndNotEmpty($favouriteEditor[ 'data' ][ 'favourite_editor_id' ]) )
        {
            throw new APICoreException(trans('messages.exception.data_not_saved'));
        }
        //If no exception thrown, save id
        $favouriteEditorId = $favouriteEditor[ 'data' ][ 'favourite_editor_id' ];
        //repare array for mig repo & save()
        if(getMigrationStatus('favourite_editor') && $operation == 'insert')
        {
            $this->saveMigrationDataElseThrowException('favourite_editor', $favouriteEditor[ 'data' ][ 'favourite_editor_id' ], 'favourite_editor', null);
        }
        // Check if any error while saving

        $dataToSave = [
            'client_feedback_id' => $clientFeedbackId,
            'files'              => keyExistsAndissetAndNotEmpty('extra',$filesSaved) ? array_keys($filesSaved['extra']) : null,
            'favourite_editor'   => $favouriteEditorId
        ];
        //@todo : return standard response
        // Return final response to the WB.
        return [
            'data'          => $dataToSave,
            'operation'     => $operation,
            'status'        => ApiResponse::HTTP_OK,
            'error_message' => isset($errorMsgWhileSave) ? $errorMsgWhileSave : null
        ];

    }

    /**
     * @param $request
     * @param $operation
     * @return array
     * @author pratik.joshi
     */
    public function convertData($request,$operation)
    {
        if(
            ($request == null || empty($request)) || 
            ($operation == null || empty($operation)) 
        )
        {
            throw new APIValidationException(trans('messages.exception.validation',['reason'=> 'either request or operation param is not provided']));
        }
        //If blank
        echo ' >> request';echo json_encode($request);
        echo ' >> operation';echo json_encode($operation);

        //Normal data conversion
        $return = $this->basicDataConversion($request, $this->table, $operation);
        echo ' >> return after basicDC';echo json_encode($return);
        //Custom data conversion
        $return[ 'client_code' ] = $request[ 'client_code' ];
        $return[ 'component_id' ] = $request[ 'component_id' ];

        if (isset( $request[ 'rating' ] ) )
        {
                $return[ 'rating' ] = $request[ 'field_cf_rating_value' ] =$request[ 'rating' ];
        }

        //Add client feedback process status, in insert default it to unread
        if($operation == config('constants.op_type.INSERT'))
        {
            $return[ 'processing_status' ] = config('constants.processing_status.UNREAD');
        }
        else if($operation == config('constants.op_type.UPDATE'))
        {
            //@todo : lumen only picks config() in lumen only, explore on how to take it from laravel
            //if its set and its valid
            $processing_status_config = array_values(config('app_constants.client_feedback_processing_status')); // Get value from app constant
            if (isset( $request[ 'processing_status' ] ) &&  in_array($request['processing_status'],$processing_status_config))
            {
                $return[ 'processing_status' ] = $request[ 'field_cf_status_value' ] = $request[ 'processing_status' ] ;
            }
        }

        //@todo : check for NO
        if (isset($request[ 'same_editor_worker' ])) {
            if($request[ 'same_editor_worker' ] == 'no')
            {
                $return[ 'wb_user_id' ] = null;
            }
            else
            {
                $return[ 'wb_user_id' ] = ComponentDetail::getLastWorkerId($request[ 'component_id' ]);
            }
        }

        //Get job title and prepend with CF
        $return[ 'name' ] = 'CF_'.Component::getComponentTitleById($request[ 'component_id' ]);

        //@todo check with EOS team for params
        $dataFieldValues = setDataValues(config('app_constants.data_fields.client_feedback'), $request);
        // unset which field we are storing in column
        $return[ 'data' ] = json_encode($dataFieldValues);

        echo ' >>  return '.__LINE__;echo json_encode($return);

        echo ' >>  request & return '.__LINE__;echo json_encode(array_merge($request, $return));
         return array_merge($request, $return);
    }


    /**
     * @param $crmTable
     * @param $crmId
     * @param $wbTable
     * @param $wbTitle
     * @return mixed
     * @throws APICoreException
     * @author pratik.joshi
     */
    public function saveMigrationDataElseThrowException($crmTable, $crmId, $wbTable, $wbTitle)
    {
        $dataToSave = Migration::prepareData([
            'crm_table'        => $crmTable,
            'crm_id'           => $crmId,
            'whiteboard_table' => $wbTable,
            'whiteboard_title' => $wbTitle
        ]);
        //Save into migration repo
        $migrationData = $this->insertOrUpdateData($dataToSave, $this->migrationRepo);
        if ( !keyExistsAndissetAndNotEmpty('migration_id',$migrationData[ 'data' ]) )
        {
            throw new APICoreException(trans('messages.exception.data_not_saved'));
        }
        return $migrationData[ 'data' ]['migration_id'];
    }
}

//And test case

<?php

use Api\Core\ClientFeedbackCore;
use App\Repositories\Contract\MigrationInterface;
use App\Repositories\Contract\ClientFeedbackInterface;
use App\Repositories\Contract\FileInterface;
use App\Repositories\Contract\MasterInterface;

class ClientFeedbackCoreTest extends TestCase
{

    public $mockClientFeedbackCore;

    public $requestForConvertData;

    public $returnBasicDataConversion;

    public $operation;

    public $convertedData;

    public $mockMigrationRepo;

    public $mockClientFeedbackRepo;

    public $mockMasterRepo;

    public $mockFileRepo;

    public $clientFeedbackCore;

    public $table;

    public $saveFailedData;

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

        $this->requestForConvertData = [
            'client_code'        => 'SHBI',
            'component_id'       => '4556',
            'same_editor_worker' => 'yes',
            'created_by'         => '83767',
            'rating'             => 'not-acceptable',
            'files'              =>
                [
                    'field_cf_not_acceptable_four' =>
                        [
                            0 =>
                                [
                                    'created_by' => '83767',
                                    'status'     => '1',
                                    'filename'   => 'manuscript_0115.docx',
                                    'filepath'   => 'sites/all/files/15-01-17/client_feedback/1484497552_manuscript_011512.docx',
                                    'filemime'   => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                                    'filesize'   => '116710',
                                    'timestamp'  => '1484497552',
                                ],
                        ],
                ],
        ];

        $this->returnBasicDataConversion = [
            'crm_table'          => 'client_feedback',
            'active'             => true,
            'last_modified_date' => '2017-03-30 11:21:23',
            'created_date'       => '2017-03-30 11:21:23',
            'created_by'         => '83767',
            'last_modified_by'   => '83767',
        ];

        $this->convertedData = [
            'client_code'           => 'SHBI',
            'component_id'          => '4556',
            'same_editor_worker'    => 'yes',
            'created_by'            => '83767',
            'rating'                => 'not-acceptable',
            'files'                 =>
                [
                    'field_cf_not_acceptable_four' =>
                        [
                            0 =>
                                [
                                    'created_by' => '83767',
                                    'status'     => '1',
                                    'filename'   => 'manuscript_0115.docx',
                                    'filepath'   => 'sites/all/files/15-01-17/client_feedback/1484497552_manuscript_011512.docx',
                                    'filemime'   => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                                    'filesize'   => '116710',
                                    'timestamp'  => '1484497552',
                                ],
                        ],
                ],
            'field_cf_rating_value' => 'not-acceptable',
            'crm_table'             => 'client_feedback',
            'active'                => true,
            'last_modified_date'    => '2017-03-30 11:21:23',
            'created_date'          => '2017-03-30 11:21:23',
            'last_modified_by'      => '83767',
            'processing_status'     => 'unread',
            'wb_user_id'            => 1131,
            'name'                  => 'CF_SHBI350',
            'data'                  => '{"field_cf_acceptable_one":null,"field_cf_acceptable_two":null,"field_cf_acceptable_four":null,"field_cf_outstanding_one":null,"field_cf_outstanding_two":null,"field_cf_acceptable_three":null,"field_cf_outstanding_three":null,"field_cf_not_acceptable_one":null,"field_cf_not_acceptable_two":null,"field_cf_not_acceptable_three":null,"field_cf_acceptable_same_editor":null,"field_cf_outstanding_same_editor":null}',
        ];

        $this->table = 'client_feedback';

        $this->saveFailedData =
            [
                'status'        => 400,
                'data'          => null,
                'operation'     => 'insert',
                'error_message' => 'data save failed error'
            ];

        //Mocking start
        $this->mockMigrationRepo = Mockery::mock(MigrationInterface::class);
        $this->mockClientFeedbackRepo = Mockery::mock(ClientFeedbackInterface::class);
        $this->mockMasterRepo = Mockery::mock(MasterInterface::class);
        $this->mockFileRepo = Mockery::mock(FileInterface::class);
        //Set mock of the Core class
        $this->mockClientFeedbackCore = Mockery::mock(ClientFeedbackCore::class,
            [$this->mockMigrationRepo,
             $this->mockClientFeedbackRepo,
             $this->mockMasterRepo,
             $this->mockFileRepo])->makePartial();
        //Set expectations
        $this->mockClientFeedbackRepo
            ->shouldReceive('getModelName')->andReturn($this->table);
        //For insert data
        $this->mockClientFeedbackCore->shouldReceive('convertData')
                                     ->with($this->requestForConvertData, 'insert')
                                     ->andReturn($this->convertedData);



    }

    public function tearDown()
    {
        // DO NOT DELETE
        Mockery::close();
        parent::tearDown();
    }

    /**
     * @test
     */
    public function method_exists()
    {
        $methodsToCheck = [
            'init',
            'store',
            'convertData',
        ];

        foreach ($methodsToCheck as $method) {
            $this->checkMethodExist($this->mockClientFeedbackCore, $method);
        }
    }

    /**
     * @test
     */
    public function validate_convert_data_for_insert()
    {
        //Mock necessary methods
        $this->mockClientFeedbackCore->shouldReceive('basicDataConversion')
                                     ->with($this->requestForConvertData, 'client_feedback', 'insert')
                                     ->andReturn($this->returnBasicDataConversion);

        ComponentDetail::shouldReceive('getLastWorkerId')
                       ->with($this->requestForConvertData[ 'component_id' ])
                       ->andReturn(1131);

        Component::shouldReceive('getComponentTitleById')
                 ->with($this->requestForConvertData[ 'component_id' ])
                 ->andReturn('SHBI350');

        $actual = $this->mockClientFeedbackCore->convertData($this->requestForConvertData, 'insert');
        $this->assertEquals($this->convertedData, $actual);

    }

    /**
     * @test
     */
    public function validate_convert_data_without_params()
    {
        $errorMessage = '';
        try{
            $this->mockClientFeedbackCore->convertData(null, null);
        }
        catch (Exception $e){
            $errorMessage = $e->getMessage();
        }
        $this->assertEquals('API Validation Error: Reason: either request or operation param is not provided', $errorMessage);

    }

    /**
     * @test
     */
    public function validate_store_without_params()
    {
        $errorMessage = '';
        try{
            $this->mockClientFeedbackCore->store(null);
        }
        catch (Exception $e){
            $errorMessage = $e->getMessage();
        }
        $this->assertEquals('API Validation Error: Reason: request param is not provided', $errorMessage);

    }

    /**
     * @test
     */
    public function validate_store_client_feedback_save_fail()
    {
        $errorMessage = '';

/*        $this->mockClientFeedbackCore->shouldReceive('convertData')
                                     ->with($this->requestForConvertData, 'insert')
                                     ->andReturn($this->convertedData);*/

        //For insert, mock separately
        //@todo : with() attribute does not work here :                                     ->with($this->convertedData,$this->mockClientFeedbackRepo)
        $this->mockClientFeedbackCore->shouldReceive('insertOrUpdateData')
                                     ->andReturn($this->saveFailedData);
        try {

            $this->mockClientFeedbackCore->store($this->convertedData);
        } catch
        (Exception $e) {
            $errorMessage = $e->getMessage();
        }
        $this->assertEquals('MigrationError: Data not saved',
            $errorMessage);
    }


    public function validate_store_migration_save_fail()
    {
        //saveMigrationDataElseThrowException
        $this->mockClientFeedbackCore->shouldReceive('saveMigrationDataElseThrowException')
                                     ->with('crmTable', 123, 'wbTable', 'wbTitle')
                                     ->andReturn($this->saveFailedData);
        try {

            $this->mockClientFeedbackCore->store($this->convertedData);
        } catch
        (Exception $e) {
            $errorMessage = $e->getMessage();
        }
        $this->assertEquals('MigrationError: Data not saved',
            $errorMessage);

    }

    public function validate_store_file_save_fail()
    {

    }

    public function validate_store_favourite_editor_save_fail()
    {

    }

    public function validate_store_proper_save()
    {

    }

}

Please help me as I am exceeding deadlines due to not completing testcases in time.

Robert Harvey
  • 198,589
  • 55
  • 464
  • 673
Pratik Joshi
  • 540
  • 1
  • 5
  • 13
  • Possible duplicate of [how much time do you spend on Unit testing?](http://softwareengineering.stackexchange.com/questions/99370/how-much-time-do-you-spend-on-unit-testing) – gnat Apr 01 '17 at 10:10
  • @gnat its not duplicate, I mean its taking me too much time to write testcase. My question is not about how much time to spend, but how to write testcase fast? – Pratik Joshi Apr 01 '17 at 10:17
  • Experience here matters ofcourse, the more you have the faster you implement the tests. However, the experience will tell you too that when the u.t takes that long (7h) may be the problem is on the class It self, not in the test. I see a good opportunity for you for learning tdd. Write first the test suit of the component thinking in all the possibles escenarios (those that may cause errors too). Then refactor the current code. Iterate till all your tests pass successfully. Then add more permutations looking for corner use cases. – Laiv Apr 01 '17 at 11:18
  • Considering asking [CodeReview.SE] to have a look at your tests. –  Apr 01 '17 at 11:24
  • 4
    @JETM The question is already cross-posted on two other sites. Cross-posting could be acceptable, but the OP should consider some common-sense cross-posting etiquette. 1) Declare the cross-post. 2) Three sites, all at once, is too much. 3) Tailor the question to the concerns and requirements of each site. – 200_success Apr 01 '17 at 13:15
  • 2
    If you're confused on what to test, how did you know what to build? The time spent deciding what to test should be part of the building process. Even if you're not doing TDD, you think of something to test, you either decided to handle that while building or it's something you're going to have to go back and fix anyway. – JeffO Apr 01 '17 at 14:17
  • 1
    You aren't writing easily testable code. Try to decompose things into small units of functionality. Following the Single Responsibility Principle, and/or DRY (Don't Repeat Yourself) can help alot to produce small classes which are individually almost trivial (compared to the monsters in your original class) to test. – Maybe_Factor Apr 03 '17 at 01:13
  • Possible duplicate of [Is it normal to spend as much, if not more, time writing tests than actual code?](https://softwareengineering.stackexchange.com/questions/299796/is-it-normal-to-spend-as-much-if-not-more-time-writing-tests-than-actual-code) – esoterik Jun 25 '18 at 21:10

2 Answers2

10

Writing test cases generally take a long time if you don't use test-driven development, because of several factors which are explained very well in Working Effectively with Legacy Code. In your specific case the store and convertData methods are long and complicated, signalling that some breaking apart (for example into public methods on DataStore and Converter classes) is necessary to make the code testable. A common code smell showing that your code is hard to test is the fact that you have a very long setUp method.

l0b0
  • 11,014
  • 2
  • 43
  • 47
7

It is not unusual for writing good tests to take considerable time - strictly you should be able to write your tests just from the specification and interface that is what is done in some formal test environments and still get 100% coverage.

One big factor is if the developers are not used to writing testable code. In businesses like Aviation there are normally specified limits to the allowable code complexity, (a McCabe of <10 is a usual guideline and a requirement to justify anything over that with hard limit of 20 is what I am used to having to stick to).

If you look at a section of code there are some rules of thumb for how many tests it will take to fully test it:

  • 1 for a simple run through then

Parameter Checks:

  • +1 for each value of an enumerate parameter in some languages +1 for invalid enumerate values.
  • +6 for each float parameter, (0.0, >0.0, <0.0, +INF, -INF, NaN)
  • +5 for each int parameter, (0, >0, <0, MAXINT, -MAXINT) etc.

Then for each decision:

  • +1 for each case in a switch
  • +2 for each of <,>, ==, !=
  • +3 for each of <=, >=

This adds up fast.

Some test frameworks have the ability to produce stubs or mocks automatically that gets you off to a good start.

As you progress you will find that:

  1. You need to do less research
  2. You will build up a library of mocks/stubs/smippets that you can reuse
  3. You will become for familiar with the tools
  4. You might even be able to educate the developers on how to write testable, maintainable, code.
Steve Barnes
  • 5,270
  • 1
  • 16
  • 18