davestewart's avatar

Where to start with building testable applications?

I've generally been OK with the code I've written over the years - that is, I'm experienced enough not to write terrible spaghetti code, but it's pretty clear now I'm looking more at making parts of my app testable, that my "good habits" aren't enough.

  • I'm good at chunking up my classes to have methods that do only one thing
  • my classes generally do only one thing
  • I'm using DI much more then before
  • My constructors now don't do very much

However, when things get complicated, I can still find it a real challenge to work out how to make classes that have a bunch of dependencies, or require a few other classes to be running and set up with config, etc, to operate more cleanly.

A lot of the tutes out there regarding testing apps give you the "this is how you test a class" info, but I've been struggling to find information regarding "thinking" in the way to build apps in a testable way from the outset.

Can anyone point me towards any good resources?

Thanks.

0 likes
20 replies
ian_h's avatar

TDD... IMO is the best way to go forward with this... before even creating your Model or Controller file etc, write a failing test for the logic of what you want it to do, for example:

/** @test */
public function show_should_return_a_200_status_code()
    $this->visit('/')
        ->seeStatusCode(200);
}

This would fail on the fact that you're yet to create your controller / method etc.. so now create that and return a basic view.. test should then pass.

Then add some more logic to the test method of what's expected to happen so that it fails again... then write your controller / model codes to do what you expect to satisfy the test until it passes... lather, rinse, repeat.

I've only recently started down this path myself too in comparison to my total coding years. Initially I struggled with the concept (how can I test for something I haven't even written yet!?) but then I got into the mind set that I'm not looking to test things line by line as I code them per-se, but more the logic / thought behind what I want to happen from it.

Although based on Lumen 5.1 (I didn't have to make many changes for 5.2).. I found https://leanpub.com/lumen-apis Writing APIs with Lumen by Paul Redmond a good book to work through (haven't yet completed it due to time / project constraints yet but what I have was a great insight IMO).

Cheers..

Ian

1 like
davestewart's avatar

Thanks guys, two great resources there! Looking forward to checking them out in the next few days :)

ohffs's avatar

I find if I'm doing something then writing the tests first (a la TDD) really helps me get the API I'd like to program with - and that helps with the implementation of the code itself. So maybe I have a class that imports a TSV file into a collection :

/* test_it_can_import_a_tsv */
$proc = new TsvProcessor();
$row = $proc->readFile('/path/to/test.tsv')->firstRow();
assert('whatever);
// etc

Then I'll maybe think 'actually, that's kinda rubbish' and try to write a different API until I get one that feels nice to work with. Then I can start to write the code roughly knowing what I'll have to inject/import to make the code work that way and not designing it as I go along (then inevitably be useing loads of stuff that I forget why it was even there and being frightened to take it out, etc etc ;-).

So you pretty much always end up with testable code as you've written it as a test :-) Back-porting tests is way more time consuming in my experience and that's where you tend to find out 'oh, actually - this is a pig to test' ;-) I'm trying to write a bunch of tests for a horrific PHP4 app just now so I can refactor it - and boy is it a pita ;-)

I think doing the TDD stuff also really helps force you to make your code more modular too - it's so much easier to write the tests for it that, slowly, it becomes a habit that makes you wince when you think about how you used to work ;-)

That said - sometimes for little crud apps I just do basic 'visit('/blah')->click('thing')->see('ohhai') as there's not much you can extract out and the API is pretty much Model::create($request->all()) ;-)

davestewart's avatar

Thanks @ohffs - you've pretty much just described why I started using tests - a large project refactor where I didn't want to break a bunch of things! If it hadn't have been for the tests, I would have likely been in a whole world of broken application pain.

Also, your comment about not knowing why stuff was there resonates! It shouldn't, but it does. "I'll just add this method, as it completes the set and I'm sure I'll use it at some point...". I just want it to be neat and complete... :P

My next project is likely going to be a Lumen/Vue combo, so hopefully quite easy to write tests for :)

ohffs's avatar

This php4 app I'm working on has classes that are 6-10000 lines long, random indentation, methods like 'form_save' followed by 'save_form' just to give you a bit of extra confusion ;-) My favourite is a method called 'get_info' which drops the DB connection, connects to another DB server I didn't know about, then uploads a jpg, then re-establishes the original db connection. And doesn't return anything. 'get_info' should really just be 'surprise!' ;-)

It's such fun to work on.... ;-) Can I do your lumen/vue app instead? Swapsies? ;-)

3 likes
pmall's avatar

Start by simple tests then go deeper. Simple tests asserting a page is actually displaying for a given request can save a lot of time when refactoring an old project. Start by doing one test per controller action.

1 like
davestewart's avatar

'get_info' should really just be 'surprise!' ;-)

@ohffs - I just laughed out loud!!

SaeedPrez's avatar

I love the tests so much, I've even written tests to test my tests :D

Edit: Now I'm thinking maybe I need some tests, to test the tests I've written to test my tests.

davestewart's avatar

@SaeedPrez - Just write a recursive testing function. Fun dependent on how much RAM you install :P

1 like
SaeedPrez's avatar

@davestewart haha, after couple of weeks of coding, I won't have any actual production code, just inception level of tests :)

1 like
MikeHopley's avatar

My favourite is a method called 'get_info' which drops the DB connection, connects to another DB server I didn't know about, then uploads a jpg, then re-establishes the original db connection.

That is just goddamn hilarious. Wow.

ohffs's avatar

@MikeHopley I've been toying with pulling together a user group talk about it - 'The Legacy Codebase of Doom' ;-)

2 likes
davestewart's avatar

@ohffs - you should at least publish some of it on GitHub, just for shits 'n' giggles

1 like
ohffs's avatar

@davestewart heh - I might do that over the weekend if I can face looking at it :-) It's hard to show just how bad it is without a lot of the surrounding mess too though (it uses global state everywhere via GET params in the URL for instance - that's fun...) but I'll see if I can extract some particular gems ;-) Though I just opened a random file and saw this (indentation is direct copy'n'paste and I've no idea who or what 'jt' might be in the "helpful" comment) :

    function makeNew(&$image){ // jt put & here to make it work!!!!!!!!======================= was ==== function makeNew($image)
        
    $userid=$image->getUserID();
    $title=$image->getTitle();
    $ext=$image->getExt();
    $sql = <<<EOS
insert into images (
userid,
title,
status,
ext,
created)
VALUES (
'$userid',
'$title',
'live',
'$ext',
unix_timestamp(now()))
EOS;
    mysql_query($sql) or die(mysql_error());
    $id=mysql_insert_id();
    $image->setImageID($id);
    
    return; 

    }

That's actually un-representively good code ;-) But it's an "exciting twist" that they're passing the $image by reference so the things they're changing affect other bits of the application without you realising how unless you've traced the path through every function. I guess it saves doing a return $image though - optimised! ;-)

Fun fun fun! ;-)

Edit: I got a little curious as to what "$image" might be (there's no docblocks or anything, natch). Grep for "makeNew" and found 30 different functions called that - but I think this is what calls it - the if ($_SESSION conditional is lovely and clear, next step figuring out what 'base_replace' might be and where that was in turn set. Repeat and rinse... ;-)

    function add_uploaded_image_to_database($newitem){
        require_once(CLASSES.'user.manager.class.php');
        require_once(CLASSES.'imagebase.manager.class.php');
        require_once(CLASSES.'imagebase.class.php');
        $imanager= new ImagebaseManager;
        $image = new ImageBase;
        $um = new UserManager();
        $user = $um->getById(Session::getSessionUser());
        $im = new ImagebaseManager();
        
        if ($_SESSION['base_replace']>0) return $this->update_image($newitem, $_SESSION['base_replace']);

        $image->setTitle($newitem['title']);
        $image->setExt($newitem['ext']);
        $image->setUserID($user->getUserID());
        $imageId=$im->makeNew($image);
        echo "ID is ".$image->getImageID();
        return $image->getImageID();
    }

At least it actually return's something I guess... ;-)

Edit again: Just noticed the end of that function :

        $imageId=$im->makeNew($image);
        echo "ID is ".$image->getImageID();
        return $image->getImageID();

And was thinking "why do they create the $imageId then not use it and call the ->getImageID() twice?" and realised it's because makeNew() doesn't actually return anything so imageId is null! Splendid stuff :-)

davestewart's avatar

I actually began to get sucked into that, preparing questions like "What class is makeNew on?" then realised it would be more fun looking up pictures of kittens rather than getting involved in helping you debug your App of Doom.

:D

pmall's avatar

@ohffs please thrash down this crap code and rewrite it from the scratch. There is no point fixing this. And I've concerns about your sanity.

ohffs's avatar

@davestewart I find myself having to look at kitten pictures a lot these days ;-)

@pmall it's being slowwwwly replaced. The first class that I completely re-wrote went from 4700 lines to 300 (including docblocks etc). And that'll go down more once I can shift the code over to a framework that can take care of a lot of stuff I'm having to implement myself. Oh happy day!

pmall's avatar

Crazyness arise when you forget to re-implement one functionality and your boss tells you "I don't understand the previous one worked just fine"

ohffs's avatar

Yeah - tell me about it :'-/ I'm pretty sure that's how the code base got so bad in the first place - no-one ever stopped to fix it as 'it works'. Which it kinda does, quite often by luck rather than design mind you...

Please or to participate in this conversation.