API Project - Part 2 - Model Objects and Testing Them
Model Objects
Some thoughts and questions before we dig in:
- What is a model object?
- What are some other names/terms for a model object?
- We should review the difference between an object and an assoc array.
- Objects can have properties and methods while assoc arrays are key/value pairs
- Models are defined by a class and can provide type safety
- Models can include logic (such as validation)
- Models can potentially include code to interact with the database (but we separate that logic out into a separate data access class)
Look at the Model class (Model.inc.php in the includes/models folder). All of the models that we create in this project will extend (inherit from) this class. Note the following:
- It is an abstract class
- It includes one abstract method, named isValid(), so when you extend the class you must implement the code for this method. The method should validate each of the properties that you add to your model object, and return true if they are all valid (otherwise it should return false).
- It has a few concrete methods that will encode a model in various formats, such as JSON, XML, and CSV. This is nice because you will not have to add code to do this in your model classes (unless you want to override the inherited behaviour).
- It has an equals() method that allows you to compare two model objects to see if they contain the exact same state (it does not check to see if two variables are pointing to the exact same object).
Now take a look at the tests I created for this class by opening this page in the browser: localhost/api/tests/ModelTests.php. Note that I could have (and should have) written many more tests for this class. Don't worry about the code in ModelTests.php write now, it's complicated and we'll get into writing our own tests later.
These are the steps you need to do in order to create your own model class:
- Create a class that extends Model (make sure to include Model.inc.php)
- Add a constructor that takes an associative array (from the database) and sets the properties of the model. The constructor will be used to create and populate the model.
- Implement the isValid() method
Now we'll go ahead and create a model class that represents a row from the user_roles table. Open Role.inc.php (in the models folder) and add this code to get started:
include_once("Model.inc.php");
class Role extends Model{
public $id;
public $name;
public $description;
}
Note:
- The class extends the Model class (and must therefore include/import it on the first line)
- There are 3 properties (instance variables) added to the class and each one will map to a column from the user_roles table.
Also note that the property names in the class are not the same as the column names in the database. This is a common problem when working with a database. When we work with Role objects we don't want to refer to their properties as user_role_id,user_role_name, and user_role_desc. Instead we would rather use id, name, and description. Later when we start working with the database, we'll see how you convert the column names to property names. But if you are interested to learn more now, you can look up ORM (object relational mapping).
Now go ahead and add this constructor function to the class (be sure to put it under the properties, but before the curly brace that closes the class:
function __construct($args=[]){
$this->id = $args["id"] ?? 0;
$this->name = $args["name"] ?? "";
$this->description = $args["description"] ?? "";
}
Note:
- There is an optional parameter that will default to an empty array
- If the parameter IS passed in, then it should be an associative array that has the following keys:
- id
- name
- description
- The keys from the associative array are used to set the properties of the Role object.
- The null coalescing operator (??) is used to set the properties to default values if the proper key does not exist.
Now add the isValid() method to the class (remember that we are required to implement this method because it is abstract in the super class):
public function isValid(){
$valid = true;
$this->validationErrors = [];
// validate the id (it must be a number greater than or equal to 0)
if(!is_numeric($this->id) || $this->id < 0){
$valid = false;
$this->validationErrors["id"] = "ID is not valid";
}
// name should be less than 30 characters
// name should not be empty
if(empty($this->name)){
$valid = false;
$this->validationErrors["name"] = "Role name cannot be empty";
}else if(strlen($this->name) > 30){
$valid = false;
$this->validationErrors["name"] = "Role name cannot be more than 30 characters";
}
return $valid;
}
Note:
- The method returns true if the role id and name are valid and returns false otherwise (we are not validating the description, although maybe we should verify that it is 200 characters or less)
- If we find something that is not valid, we add a message to the validationErrors property which is inherited from the Model class. The validationErrors property is an associative array and when we add a message to it, we set it's key to the name of the property that is not valid.
Now let's test the Role class. Open RoleTests.php which is in the tests folder. Then add this code to it:
include_once("../includes/models/Role.inc.php");
// we need an associative array to pass into the constructor
$options = [
"id" => 1,
"name" => "Test Role",
"description" => "This role is for testing"
];
// When each test completes, the result message will get added to this array:
$testResults = [];
// This function will run tests on the constructor in the Role class:
testConstructor();
// This function will run tests on the isValid() method:
testIsValid();
// This will echo the test results:
echo(implode("<br>", $testResults));
function testConstructor(){
global $testResults, $options;
$testResults[] = "<b>Testing Constructor</b>";
// We'll add test code here
}
function testIsValid(){
global $testResults, $options;
$testResults[] = "<b>Testing isValid()</b>";
// We'll add test code here
}
Note:
- We include/import the Role class
- We create the $options associative array so that we have something to pass into the constructor (of the Role class)
- The $testResults array will eventually get populated with strings that tell us whether or not each test passes
- We are calling two 'test' functions (one for the constructor, and one for the isValid() method) We'll be adding test code to these function in the next step
- After the 'test' functions have run, the $testResults array should be loaded with strings that tell us which tests have passed and which ones have failed, so we use the implode() function to smush all the strings into one (separated by a BR tag) and then echo them into the body of the response.
Go ahead and run this page in the browser. It doesn't actually run any tests yet, but it's important for you to understand how the code flows and populates the $testResults array. You may even want to step through the code line by line with the debugger.
Now we'll add test code to the testConstructor() function, add this code to the body:
// Test 1 - Make sure we can instantiate a Role object
$r = new Role();
if($r){
$testResults[] = "PASS - created instance";
}else{
$testResults[] = "Fail - did not instance";
}
// Test 2 - Make sure that the id property gets set properly
$r = new Role($options);
if($r->id === 1){
$testResults[] = "PASS - id set properly";
}else{
$testResults[] = "Fail - id NOT set properly";
}
// Test 3 - Make sure that the name property gets set properly
$r = new Role($options);
if($r->name === "Test Role"){
$testResults[] = "PASS - name set properly";
}else{
$testResults[] = "Fail - name NOT set properly";
}
// Test 4 - Make sure that the description property gets set properly
$r = new Role($options);
if($r->description === "This role is for testing"){
$testResults[] = "PASS - description set properly";
}else{
$testResults[] = "Fail - description NOT set properly";
}
Go ahead and reload the page in the browser and you should see the results of our tests. What we are doing with each test is calling the constructor and then verifying that each property in the Role instance is set how we expect it.
This is how unit testing works, you call functions and verify that they work the way you expect them to. If they don't, then a bug has surfaced. In class we should add a bug to the Role class and run the tests to see if they catch it (remind me if you want to do this).
Now we'll go ahead and add the tests for the isValid() method, add this code to the body of the testIsValid() method:
//isValid() should return false if ID is not numeric
$r = new Role($options);
$r->id = ""; // set the id to an INVALID value
if($r->isValid() === false){
$testResults[] = "PASS - isValid() returns false when ID is not numeric";
}else{
$testResults[] = "FAIL - isValid() DOES NOT return false when ID is not numeric";
}
//isValid() should return false if ID is a negative number
$r = new Role($options);
$r->id = -1;
if($r->isValid() === false){
$testResults[] = "PASS - isValid() returns false when ID is a negative number";
}else{
$testResults[] = "FAIL - isValid() DOES NOT return false when ID is a negative number";
}
// if the ID is not valid, the validationErrors should contain an ID key
$r = new Role($options);
$r->id = -1;
$r->isValid();
$errors = $r->getValidationErrors();
//var_dump($errors);
if(isset($errors["id"])){
$testResults[] = "PASS - ID key exists in the validation errors";
}else{
$testResults[] = "FAIL - ID key DOES NOT exist in the validation errors";
}
// The role name should not be empty
$r = new Role($options);
$r->name = "";
if($r->isValid() === false){
$testResults[] = "PASS - isValid() returned false when the name is empty";
}else{
$testResults[] = "FAIL - isValid() DID NOT return false when the name is empty";
}
// The role name should not be more than 30 characters
$r = new Role($options);
$r->name = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
if($r->isValid() === false){
$testResults[] = "PASS - isValid() returned false when the name is too long";
}else{
$testResults[] = "FAIL - isValid() DID NOT return false when the name is too long";
}
// If the role name is empty there should be an error messsage that says 'Role name cannot be empty'
$r = new Role($options);
$r->name = "";
$r->isValid();
$errors = $r->getValidationErrors();
if(isset($errors["name"]) && $errors['name'] === "Role name cannot be empty"){
$testResults[] = "PASS - the error message for the name was correct when name was empty";
}else{
$testResults[] = "FAIL - the error message for the name was NOT correct when name was empty";
}
// If the role name is more than 30 characters there should be an error messsage that says 'Role cannot be more than 30 characters'
$r = new Role($options);
$r->name = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
$r->isValid();
$errors = $r->getValidationErrors();
if(isset($errors["name"]) && $errors['name'] === "Role name cannot be more than 30 characters"){
$testResults[] = "PASS - the error message for the name was correct when the name was too long";
}else{
$testResults[] = "FAIL - the error message for the name was NOT correct when the name was too long";
}
Go ahead and reload the page in the browser to see the test results.
Let me know if you want to discuss any of the tests, they can be extremely tedious! You may also want to step through each one in the debugger (in class remind to step through this code together).
Hopefully you are beginning to understand how the isValid() method works. It includes a series of IF statements that checks each property in the Role object. If a property is not valid, then it adds a key to the $validationErrors array (which is declared in the super class). The key is set to the name of the property that is invalid, and the value is a string/message that indicates why the property is not valid.
Now that we have tested the Role model class, we can have some confidence that it is working properly before we incorporate it into our project (actually, we could have written many, many more tests but at least we have some confidence that it's working).
Finally, open this page in the browser: http://localhost/api/tests/UserTests.php. And note that (most of) the tests are failing.
Soon, if I haven't already done so, I'll assign you to finish off the code in the User model object, and make sure that all the tests pass.
You can begin working on model objects for the tables that are in your final project (and testing them too).