Building Backcast, Part 4 | Tom McFarlin

[ad_1]

TL;DR: The private podcast account is set up but not yet recorded; I’m looking to do that sooner rather than later. I’m looking to possibly abandon exclusive Overcast support which I’ll briefly talk about. This article walks through the process of defining the conditions for unit testing and how I’ve decided to go about writing the first set of unit tests.

Building Backcast, Unit Testing

Where Do I Go From Here?

Originally, this post was going to be a combination of setting up PHPUnit and getting up and running with writing unit tests but, if you’ve been reading along, then you saw how well that went over.

Honestly, I’m glad I encountered that early on in the process as it shows the random rabbit holes any of us end up in whenever we’re writing code.

Secondly, after running into a hurdle with authentication with Overcast and deciding to just go with a manual export of podcast feeds, I started looking at a couple of different podcast applications to see how they exported their feeds.

Ultimately, I was looking for a set of standards (which would include common attributes) that I could use to evaluate the validity of a file and to update the backup with only new information. (Of course, the first import is always going to be the largest.)

I asked myself the following questions:

  • Do I look for a file with a specific name?
  • Do I verify the content of the file?
  • Do I accept any XML file?
  • Do I need to verify the size or date of the file?

Some of these questions, like many, lead me into thinking about other things, too, but in trying to make as much progress as possible with as little friction as necessary, I decided to do the following:

  1. Verify the type of file (that is, an opml file),
  2. Verify that it’s well-formed or, rather, valid XML,
  3. Verify four attributes of the XML file that appear to be common across podcast exports. (I’m going to test for the opml tag, the rss value for the type attribute, the outline tag, and the xmlUrl key.)

If any of these fail, then I’ll assume that I have an incorrect export. That seems to be a reasonable balance for now. And now that I have PHPUnit set up and running correctly, I can start writing unit tests.

Test Data

Before writing the tests, I want to make sure I have a couple of export files that I can test against. This, I hope, will verify the validity of the tests.

I’m creating an exports directory in the tests directory and dropping in an export from Overcast and from Pocketcasts.

And now that I’ve that done, I’ve merged the current feature branch into the develop branch.

A Note About My GitHub Workflow

In case I’ve not described this before, my workflow for this project includes creating a develop branch off of main and then a corresponding feature branch off of develop so that I can work on a single branch.

As it stands, I’m trying to keep each branch with a specific post. I’ve not really shared them yet because it’s a private repository but once I have enough functioning code to throw out into the public, I’ll open the repository.

Maybe by the end of this post, even.

Writing Tests

Before writing tests, I need to do the following:

  • set up a core class in the src directory,
  • set up the autoload functionality in the composer.json configuration file,
  • and verify that PHPUnit can access my class

Once that’s done, I can actually get into writing tests. But I need to get this scaffolding done first.

Set Up a Core Class That’s in a Namespace

Initially, I’d started out with a constant defined in the main file, but I’m going to move away from that. The fastest way for me to get from nothing-to-something is to set up a single class, for now, that will encapsulate the functionality I’m trying to test.

This is the part where I rationalize not doing BDUF.

Big Design Up Front (BDUF) is a software development approach in which the program’s design is to be completed and perfected before that program’s implementation is started. It is often associated with the waterfall model of software development.

Wikipedia

I share a little bit more in my Scattered Thoughts in the section at the end of this post.

Refactoring the Bootstrap

First, I’m creating a new branch (aptly called feature/add-validation-tests since that’s what I’ll be doing in this post) and I’m going to clean out the code in the core backcast.php file, or the bootstrap file, so that I can use it to instantiate an object from the class I’m going to be writing.

This means the bootstrap file will look like this:

#!/usr/local/bin/php
<?php
namespace Backcast;

require 'vendor/autoload.php';

new Backcast();

Note here, though, that I’m including the autoload.php file generated by Composer.

Define Autoload Functionality

Before going any further, let me share what the composer.json file looks like:

{
  "name": "tommcfarlin/backcast",
  "description": "An application used to backup podcast subscriptions via podcast XML exports.",
  "require-dev": {
    "phpunit/phpunit": "^8"
  },
  "autoload": {
    "psr-4": {
      "Backcast\": "src"
    }
  },
  "autoload-dev": {
    "psr-4": {
        "Backcast\Tests\": "tests"
    }
  }
}

The two things I want to call out are the autoload and the autoload-dev areas. The first section tells composer where to locate files for the autoloader which is ideally used in production environments. The second section tells Composer that only the files in this area are intended for non-production environments.

This means when you run composer dump-autoload --no-dev on the command-line, nothing will be generated for the tests directories.

With that said, let’s set up the core class.

The Initial Backcast Class

First, I’m going to add a Backcast.php file to the src directory. I want to easily verify it’s been instantiated so to make this easy, it looks like this:

<?php

namespace Backcast;

class Backcast {
  public function __construct() {
    echo 'This class has been instantiated.';
  }
}

And I’m going to update the bootstrap so it looks like this:

#!/usr/local/bin/php
<?php
namespace Backcast;

require 'vendor/autoload.php';

new Backcast();

Before doing anything else, I’m going to run $ composer update on the command-line to make sure the autoloader is updated. Once done, I’ll try executing the main script again by calling $ ./backcast.php.

Given the constructor’s statement above, I should see This class has been instantiated when the bootstrap is run (which I do).

Though I’m not ready to actually commit any code yet, I’ve got what I need to make sure PHPUnit can access the class and I can begin writing both unit tests and functions to pass the tests.

Verify PHPUnit Can Access the Class

Right now, I know there are going to be two ways to start Backcast and that’s via the command-line and through unit tests.

If instantiated via the command-line, I can use the command-line arguments; otherwise, I’ll need to manually pass the string into the constructor of the class. This means I need to verify that if I’m using the command line, the code will:

  1. Validate the number of arguments,
  2. Pass the first argument that it identifies since that’s what it expects as the class,
  3. And provide a way to evaluate if the file exists.

The first two steps can be achieved in the bootstrap by checking the number of arguments before instantiating the class:

#!/usr/local/bin/php
<?php
namespace Backcast;

require 'vendor/autoload.php';

if ($argc !== 2) {
  die("Please provide the path to the export file.");
}

new Backcast($argv[1]);

Everything above will be the same when using unit testing (or invoking it via code) except the constructor will accept its arguments as a string passed in from an invoking function.

This means the core functionality boils down to: Provide a way to evaluate if the file exists. So that will be my first unit test.

Testing The Code

First, I need to delete the SampleTest.php that I created to ensure that PHPUnit was working. Then I’ll add BackcastTest.php since I’ll be using a single class, for the time being.

Now I can start writing my tests.

1. Verify The File Exists

To test that the file exists, I’m going to write two unit tests that will test the following conditions:

  • The file does not exist,
  • The file does exist.

This won’t verify if they are valid or not, that will come later. My thinking is since no action can be taken until we have a file, then I need to test that first.

I need to make sure that the class throws an exception if no arguments are provided. This can be done with the following unit test:

public function testNoArgumentsException(): void
{
  $this->expectException(ArgumentCountError::class);
  $backcast = new Backcast();
}

Then I need to test if the file does not exist which I can do with an empty string:

public function testFileDoesNotExist() : void
{
  $backcast = new Backcast('');
  $this->assertFalse($backcast->exportFileExists());
}

And finally I need to test if the file exists which I can do using the same methodology:

public function testFileExists(): void
{
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->exportFileExists());
}

Now I need to write the class to satisfy the tests. The class should be simple enough, though. It needs to:

  • accept a string,
  • assign it to a class property,
  • use a native PHP function to determine if the file exists,
  • and return the result of the evaluation.

That can all be captured in a few lines of code.

<?php

namespace Backcast;

class Backcast {

  private $xmlExportPath;

  public function __construct( string $xmlExportPath ) {
    $this->xmlExportPath = $xmlExportPath;
  }

  public function exportFileExists() : bool {
    return file_exists( $this->xmlExportPath );
  }
}

And now if I run PHPUnit I should see all green tests (technically, there should be three tests and three assertions).

And I’m good. At this point, I’ll commit the code to the repository and then move on to the next set of tests.

2. Verify The File Type

To verify that this is a valid file type, I just want to assert that I have an opml file.

The OPML specification defines an outline as a hierarchical, ordered list of arbitrary elements. The specification is fairly open which makes it suitable for many types of list data.

Wikipedia

Podcast export files are XML files in the format of opml. It would stand to reason that checking the file suffix is one way to do it, and that’s true, but one could just as easily rename the suffix of, say, an image file to opml and pass the test.

So I’m going to test that this has the proper suffix and that that the contents appear to be opml.

First, the suffix test. This includes the following test:

public function testIsValidFileType(): vaoid
{
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->hasValidFileType());
}

And then it includes the following code:

public function hasValidFileType() : bool {
  $fileParts = explode('.', $this->xmlExportPath);

  if (!isset($fileParts[1])) {
    return false;
  }

  return (
    'opml' === strtolower($fileParts[1])
  );
}

After that, I think I’ll use the SimpleXML library that ships with PHP to traverse the the file itself. But first, I need to commit this test and function to the current feature branch.

3. Verify It’s Valid XML

To verify it’s valid XML, I want to verify the opml tag is present and then I want to make sure that PHP doesn’t throw any errors whenever there is a problem loading the entire file.

Honestly, verifying that there’s an opml file tag present in the XML file seems off but in order to keep and to keep shipping this thing, I’m going with classic string matching.

See the test:

public function testIsValidOpml() : void
{
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->containsOpmlTag());
}

And the code:

public function containsOpmlTag() : bool {
  return (
    false !==
    strpos(
      file_get_contents( $this->xmlExportPath ),
      '<opml'
    )
    );
}

Next, I want to make sure that the XML file is valid. The thing is, I can’t know for sure if a DTD has been defined so I need a way to determine if the XML has any errors in it without comparing it to an actual DTD.

The best way I know to do this is to use DOMDocument library (so this is a slight deviation from SimpleXML at the moment) and determine if it throws any errors (from experience, I’m not sure if this is the best way to do it but if it’s sufficient, then why not?).

So here’s the test that will evaluate the validity of the file:

public function testIsValidOpml() : void
{
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->isValidOpml());
}

And here’s the function:

public function isValidOpml() : bool {
  libxml_use_internal_errors(true);

  $domDoc = new DOMDocument();
  $domDoc->load($this->xmlExportPath);

  return 0 === count( libxml_get_errors() );
}

The final thing I want to test is whether or not I see the attributes I expect to see.

4. Verify the Attributes Are What I Expect

Earlier in this article, I said that I wanted to make sure that I tested for the opml tag, the type attribute with an rss value and the outline tag with the xmlUrl. At this point, I have the valid opml tag so I can cross that off the list.

But let’s traverse the document and file all of the outline tags that have a type attribute and verify that they all have the rss value. To do this, we can load up the document using SimpleXML and get to work.

As I’ve been doing, I’m going to write the test first:

public function testHasProperOutlineTags() {
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->hasProperOutlineTags());
}

Then I’ll share the final version of the function that I’ve implemented (with comments included, if necessary) to show how it’s working):

public function hasProperOutlineTags() : bool {
  $xmlDoc = simplexml_load_file( $this->xmlExportPath );

  if ( 0 === count($xmlDoc->body->outline) ) {
    return false;
  }

  foreach ( $xmlDoc->body->outline as $node ) {
    if ( ! isset( $node->outline['type'] ) || 'rss' !== strtolower( $node->outline['type'] ) ) {
      return false;
    }
  }

  return true;
}

Since the xmlUrl is another key in in the outline tag, then this is a time where I can refactor the above function so it only loads the file once.

So I’m going to do that first. I’m also going to mark it as private as it’s a utility function inside of the class meant solely for loading a file and, at this point, we know the file should exist.

private function loadXmlExport() : SimpleXmlElement {
  return simplexml_load_file( $this->xmlExportPath );
}

Next, I’ll make the obvious modification to update the code prior to this one so that it invokes this function (which you can see in the repository linked at the end of the article) and then I’m going to go through the process of verifying that xmlUrl‘s are present for each of the outline tags.

public function testHasProperXmlUrls() {
  $path = __DIR__ . '/exports/sample.opml';
  $backcast = new Backcast($path);
  $this->assertTrue($backcast->hasProperXmlUrls());
}

And now the code:

public function hasProperXmlUrls() : bool {
  $xmlDoc = $this->loadXmlExport();

  if ( 0 === count($xmlDoc->body->outline) ) {
    return false;
  }

  foreach ( $xmlDoc->body->outline as $node ) {
    if ( ! isset( $node->outline['xmlUrl'] ) || ! filter_var( $node->outline['xmlUrl'], FILTER_VALIDATE_URL ) ) {
      return false;
    }
  }

  return true;
}

Above, I’m using one of my favorite utility features for evaluating URLs and that’s the filter_var function. This is something that I don’t often seen used, at least in the context of WordPress in which I plan on eventually incorporating this code, so whenever I have the opportunity to use it I try to make sure I do so and share it.

Until Part 5

I didn’t get to the point where I was able to record the first episode of the podcast, but perhaps I can do that next week. Further, I’m thinking of making sure the number of outline elements matches the same xmlUrl and type keys to ensure that every element has the minimum required values.

In any case, the repository is officially public and the develop branch – which is the only branch that should be watched right now – is available. Note that if you’re reading this an open an issue or anything like that, I’ll likely close it because I’m not at a place where I’m ready to begin taking contributions.

But again, this is part of building it out in the open so we’ll see.

Scattered Thoughts

  • Pocketcasts is a really nice app so much so I’m considering actually using it as my primary podcast player. More on that later, though.
  • I’m no fan of god-classes or breaking object-oriented principles, but having to do this for the sake of getting something going quickly and that evaluates the logic necessary for the utility to run out weighs the work needed to dive into the whole OOP background that I’ll inevitably discuss. It’s a tradeoff, as with many things, and this is where I’m opting to make said trade.
  • I really need to install phpcs at the project level. I’m tired of the mismatched standards I’ve been using (this is going to impact how the code reads in previous posts, sorry 🤷🏻‍♂️) but it’ll look good in the repository.

[ad_2]

Source link