FrancisChung

Testing with Phpunit, Processwire & PHPStorm

Recommended Posts

I'm writing this to give back something to the community that has given so much up front over the past year.
I noticed there's hardly any discussion about testing in these forums so I decided to write this quick primer to get some discussion going.

I'm by no means an expert on phpunit or selenium but I had to jump through a few hoops to get it working (especially with PHPStorm), so I thought I figured I should share my experiences with the community. 

Also, I'm hoping non Phpstorm users can still  pick something up useful in this post.


Prerequisites : It is assumed Phpunit (https://phpunit.de/) is installed via Composer,   Selenium (http://www.seleniumhq.org/) and Php-webdriver for Selenium (https://github.com/facebook/php-webdriver) is preinstalled.

For Phpstorm users, there's a fairly detailed installation and unit testing instructions here (https://www.jetbrains.com/help/phpstorm/2016.1/testing.html)
I found some parts of it leaving me with unanswered questions, so I'm hoping this post will supplement any questions that you might encounter.

Rather than writing a single monolithic post, I will write several posts covering different topics.

  • Like 3

Share this post


Link to post
Share on other sites

Yeah Testing is quite important, sadly i will admit, I haven't been testing lately, however I will watch out for this space as I heavily use PHPUnit before and it's quite important please feel free to post, we will contribute to this ;) 

Share this post


Link to post
Share on other sites

PHPUnit configuration in PHP Storm :
This is where I got stuck for a while. The documentation surrounding this was unclear or unspecific at best.

The trick was to configure PHPStorm (in preferences) to use a Custom Autoloader that you specify and also define a default configuration file, both which were not apparent to me with the available documentation I could find.

The Custom Autoloader needs to point to autoload.php for Composer (hence my earlier pre-requisite of installing Phpunit via Composer).

The default configuration file is a XML file that defines certain basic variables that PHPUnit in order to function.

The one I've attached is a fairly basic one, with custom testsuites defined being the main difference.  The custom testsuites elements will become apparent once I explain how I structured my tests.  

For those interested in a detailed breakdown of the XML config, there are  some detailed documentation at  https://phpunit.de/manual/current/en/appendixes.configuration.html.

I've attached a screenshot of my preferences

PHPUnit PHPStorm config.png

phpunit.xml

  • Like 1

Share this post


Link to post
Share on other sites

Test Structure:

Now that we've setup PHPUnit with PHPStorm, let's talk about how to structure and setup your tests. This is where people can vary in their strategy and approaches quite a bit. Would be great to see how others approach it.

It's probably a good idea to isolate your tests away from your code. For example I've put my tests in /site/templates/php/tests

I've structured my tests into 3 basic folders that represent the different servers my website resides on.
They are core, uat and live.

The core folder contains the basic core versions of my tests. They also point to my local dev server for testing purposes.

The uat and live folder contains derived versions of these core tests that points to UAT & Live servers respectively.

The idea with having 3 seperate folders is that you can run isolated tests on each servers easily during testing and deployment phases.


Hopefully it's all been straight forward so far. I will go into detail about the tests themselves in the next post.






 

Test directory structure.png

  • Like 1

Share this post


Link to post
Share on other sites

Anatomy of a Test (Part 1):

Now that we have a test structure in place, you might be asking yourself "what sort of tests should I be writing?"

In general, you should start writing tests that would test the most important aspect of your site / system. 

For a CMS like Processwire, that would be tests that would cover Content Integrity for example, as content is king in a CMS.
Tests that would cover navigation could be another example as growing content means growing navigational possibilities.

You can use tools to determine how much of your written unit tests actually test your site/system, but test coverage is beyond the scope of this post.

Let's look at example content unit test to see what's actually inside.
 

<?php
/**
 * Created by PhpStorm.
 * User: FrancisChung
 * Date: 09/03/16
 * Time: 19:06
 */


namespace Test;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
//use Facebook\WebDriver\WebDriverCapabilities;

require_once(__DIR__."/../../../composer/vendor/autoload.php");

class WebFingerspieleContentTest extends \PHPUnit_Framework_TestCase {
    protected $site;

    protected function setUp()
    {
        $this->site="http://localhost";
    }

    public function testSafari()
    {
        $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::safari());
        $this::TestPageTitle($browser);
        $browser->close();
        $browser->quit();
        $browser = null;

    }


    public function testFirefox() {
    $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::firefox());
    $this::TestPageTitle($browser);
    $browser->close();
    $browser = null;
    }


    public function testChrome() {
    $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::chrome());
    $this::TestPageTitle($browser);
    $browser->close();
    $browser = null;
    }


    private function TestData()
    {
        return array(
            array("{$this->site}/fingerspiele/alle-fingerspiele/10-kleine-zappelmaenner/", 'Zehn kleine Zappelmänner | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/der-osterhase/", 'Der Osterhase | Fingerspiel für Kinder | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/weiss-wie-schnee/",'Weiß wie Schnee | Fingerspiel Sommer | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/ein-kleines-auto/", 'Ein kleines Auto | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/es-regnet-ganz-sacht/", 'Es regnet ganz sacht | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/das-ist-der-daumen/", 'Das ist der Daumen | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/plumps-und-platsch/", "Plumps und platsch | Fingerspiel | Sprachspielspass.de"),
            array("{$this->site}/fingerspiele/alle-fingerspiele/fuenf-engelchen/", 'Fünf Engelchen | Fingerspiel Winter | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/erst-kommt-die-schnecke/", 'Erst kommt die Schnecke | Fingerspiel | Sprachspielspass.de')
        );
        //

    }


    /**
     * @param $browser
     */
    private function TestPageTitle($browser)
    {
        $data = $this::TestData();
        //TestFactory::PageTitleTest($data, $browser, $this);

        foreach($data as $testItem) {
            $url = $testItem[0];
            $expected = $testItem[1];
            $browser->get($url);
            $this->assertContains($expected, $browser->getTitle());
        }
    }
}


Now the 2 use statements  (DesiredCapabilities & Webdriver) are needed to access the Php-webdriver that we will be using to interact with Selenium. 

The require_once(__DIR__."/../../../composer/vendor/autoload.php") statement is to reference the Phpunit test framework that we installed via Composer (I got stuck on here for a while).

Your test also needs to derive from \PHPUnit_Framework_TestCase as you can see in the above example.

This is all the dependencies you need to write a Phpunit test for Processwire that runs on Phpstorm.

In the next post (Part 2 of this topic), I will go through each method and explain their purpose and function.

  • Like 2

Share this post


Link to post
Share on other sites

@FrancisChung Thanks for writing this tutorial, but I'd suggest changing the title. You're describing functional or integration tests and not unit tests. Unit tests try to test the smallest units of software with as few external dependencies as possible (e.g. a method or a class). Testing browser output is possibly the largest testable unit of a website. I know that these names are almost never used 100% correctly, but I think in this case it would really make sense to correct the terminology.

  • Like 1

Share this post


Link to post
Share on other sites
50 minutes ago, LostKobrakai said:

@FrancisChung Thanks for writing this tutorial, but I'd suggest changing the title. You're describing functional or integration tests and not unit tests. Unit tests try to test the smallest units of software with as few external dependencies as possible (e.g. a method or a class). Testing browser output is possibly the largest testable unit of a website. I know that these names are almost never used 100% correctly, but I think in this case it would really make sense to correct the terminology.

 

Understand where you're coming from. Unfortunately, a lot of my classes actually have a simple output method and that method would output what is seen in the browser in its entirety so the smallest testable unit for most of my classes is also the largest. I've changed or removed terminology to remove any ambiguity or confusion.
 

Share this post


Link to post
Share on other sites

Anatomy of a Test (Part 2):

Now let's dive in a little and examine each of the functions to give you a better idea of what's going on.

    protected function setUp()
    {
        $this->site="http://localhost";
    }

setUp:

This function is called by Phpunit (note the lowercase Camel spelling, as it will break if not spelt like this) to setup your test fixtures. In a nutshell, this is the place to setup any dependencies for the tests. This is a function you should implement for each Test class.


I use this to mainly specify the URL of the site I'm going to test. As you will see later, I can easily reuse this test on other sites using this feature.

More info on fixtures can be found here --> https://phpunit.de/manual/current/en/fixtures.html

 

    public function testSafari()
    {
        $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::safari());
        $this::TestPageTitle($browser);
        $browser->close();
        $browser->quit();
        $browser = null;

    }


    public function testFirefox() {
        $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::firefox());
        $this::TestPageTitle($browser);
        $browser->close();
        $browser = null;
    }


    public function testChrome() {
        $browser = RemoteWebDriver::create('http://localhost:4444/wd/hub', DesiredCapabilities::chrome());
        $this::TestPageTitle($browser);
        $browser->close();
        $browser = null;
    }

test<Browser>:

Any functions with a "test" prefix (note lowercase spelling here) will be called by the Phpunit framework, so you can see I have 3 test functions for the framework to call.

Each of my "external" test functions represent a test case corresponding to a particular browser. 
Obviously you don't have to set it up like this, but my choices were somewhat dictated by my dependency on Php-webdriver.

The first line of code is invoking the Php-webdriver and passing it the location of the <xxx> and telling it what properties (DesiredCapabilities) you want.
Once this is successful, I call some internal test functions which I will go through next.

    private function TestData()
    {
        return array(
            array("{$this->site}/fingerspiele/alle-fingerspiele/10-kleine-zappelmaenner/", 'Zehn kleine Zappelmänner | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/der-osterhase/", 'Der Osterhase | Fingerspiel für Kinder | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/weiss-wie-schnee/",'Weiß wie Schnee | Fingerspiel Sommer | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/ein-kleines-auto/", 'Ein kleines Auto | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/es-regnet-ganz-sacht/", 'Es regnet ganz sacht | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/das-ist-der-daumen/", 'Das ist der Daumen | Fingerspiel | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/plumps-und-platsch/", "Plumps und platsch | Fingerspiel | Sprachspielspass.de"),
            array("{$this->site}/fingerspiele/alle-fingerspiele/fuenf-engelchen/", 'Fünf Engelchen | Fingerspiel Winter | Sprachspielspass.de'),
            array("{$this->site}/fingerspiele/alle-fingerspiele/erst-kommt-die-schnecke/", 'Erst kommt die Schnecke | Fingerspiel | Sprachspielspass.de')
        );
        //

    }

    private function TestPageTitle($browser)
    {
        $data = $this::TestData();
        //TestFactory::PageTitleTest($data, $browser, $this);

        foreach($data as $testItem) {
            $url = $testItem[0];
            $expected = $testItem[1];
            $browser->get($url);
            $this->assertContains($expected, $browser->getTitle());
        }
    }

Test<TestName>:

These functions should represent each unit of testing you're interested in. So in my example, I'm testing to see a web page has to expected page title.

So TestPageTitle will loop through a set of test URLs and expected titles and report any failures using the assertContains.

 $this->assertContains($expected, $browser->getTitle());

By all means, there are other ways to assert your tests other than assertContains, so feel free to explore them.

Edited by FrancisChung
More info on setUp method

Share this post


Link to post
Share on other sites

Test Leveraging:

One of the advantages of setting up your tests like this is that you can easily create tests for other sites by merely creating a subclass and defining the URL of the new site you want to test.

class WebFingerspieleContentUATest extends WebFingerspieleContentTest {

    protected function setUp()
    {
        $this->site = "http://finger-spiele.com";
    }


    protected function tearDown()
    {

    }
}

Also note that in my earlier post, Test Structure, I've organised the tests separately per site. So it's easy to run tests or a set of tests on one or multiple sites. 
It certainly saved me a great deal of time testing during UAT, Live deployment .... 

 

Final Notes:

1) The way I've structured is by no means the best or even right(?) way for you. I would like to hear suggestions or alternative ways of structuring your tests.

2) The Selenium test framework is not always up to date with the latest browser updates, so there's a chance that your browser that you're testing does not launch.
As time of writing, I'm aware of a problem with Firefox and maybe Chrome as well. It certainly wasn't the case when I was using it extensively.
Unfortunately, I don't have a good workaround of this except perhaps rolling back your browser to a last known working version.

3) I should add that you need to have the Selenium server running in the background for the tests to work. 
 

java -jar selenium-server-standalone-2.50.1.jar

4) For chrome, I think you need a chromewebdriver in your Selenium folder and execute the following

java -Dwebdriver.chrome.driver="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" -jar selenium-server-standalone-2.53.0.jar

5) This concludes the mini tutorial. I hope it was informative to some one out there, and please leave some comments. Would love some feedback. 

Share this post


Link to post
Share on other sites

quite an old thread, but I started to test some basic functionality and I can't get this to work... :(

I don't use selenium but only PHPUnit to test some functions within modules. But whatever I try it fails.

Basically I can decide whether I want to see this message:

PDOException: You cannot serialize or unserialize PDO instances

or - if I follow these rules https://blogs.kent.ac.uk/webdev/2011/07/14/phpunit-and-unserialized-pdo-instances/ -

ERROR: application encountered an error and can not continue. Error was logged.

 

Anyone who struggled with this as well? And - more important - any suggestions or solutions?

 

big thanks!

 

btw @FrancisChung: based on the stackoverflow post I think you just pasted the annotations at the wrong place. It must be outside the class {} ;) 

Share this post


Link to post
Share on other sites
6 hours ago, chrizz said:

 

btw @FrancisChung: based on the stackoverflow post I think you just pasted the annotations at the wrong place. It must be outside the class {} ;) 

2

Which annotations are you referring to, and which class {} ?

Share this post


Link to post
Share on other sites

i was referring to your post here:

https://stackoverflow.com/questions/34225720/keep-getting-you-cannot-serialize-or-unserialize-pdo-instances-in-phpunit-usin

namespace Test;

include_once(__DIR__."/../../../../index.php");     //Bootstrap to Processwire CMS

class ImageTest extends \PHPUnit_Framework_TestCase {

    /**
     * @backupGlobals disabled
     * @backupStaticAttributes disabled
     * @runTestsInSeparateProcesses
     * @runInSeparateProcess
     * @preserveGlobalState disabled
     */


    protected function setUp()
    {
        //$this->testpages = wire(pages)->find("template=fingergames|template=songs|template=poems");
    }

[...]
}

 

And no, I am not using PHPStorm but Netbeans. Finally I managed it use PHPUnit with PW modules. 

In the end it was a combination of a config XML

backupGlobals="false"
backupStaticAttributes="false"

bootstrapping the PW index.php

and include a setUp method within the test class with the correct namespaces

Example (maybe someone needs something like this)

<?php  
use PHPUnit\Framework\TestCase;

class testMe extends TestCase {
	
  protected function setUp() {
	global $wire;
	$this->wire = $wire;
  }
	
  public function test_getNextRuntimeFromCycle() {
	$res = $this->wire->modules->get("testMe")->myMethodWorks();
	$this->assertEquals(true, $res);
  }	
}

 

 

 

Share this post


Link to post
Share on other sites
    /**
     * @backupGlobals disabled
     * @backupStaticAttributes disabled
     * @runTestsInSeparateProcesses
     * @runInSeparateProcess
     * @preserveGlobalState disabled
     */

When you say annotations, if you are referring to the above, then in my project they are inside the class definition.
If you managed to get it working outside the class, then perhaps it doesn't matter as much whether they stay inside or outside the class.


If the PHPUnit documentation says it should reside outside the class, then people should have that in mind if they are still getting errors.

I've reviewed my tutorial and whilst I did describe the setUp method in detail, I probably should mention that it's a method you must include and implement. I will change it accordingly. 

 

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

  • Recently Browsing   0 members

    No registered users viewing this page.

  • Similar Content

    • By tpr
      ProcessNetteTester
      Run Nette Tester tests within ProcessWire admin.
      (continued from here)

      Features
      AJAX interface for running Nette Tester tests, in bulk or manually display counter, error message and execution time in a table run all tests at once or launch single tests show formatted test error messages and report PHP syntax errors stop on first failed test (optional) hide passed tests (optional) display failed/total instead passed/total (optional) re-run failed tests only (optional) auto scroll (optional) include or exclude tests based on query parameters start/stop all tests with the spacebar reset one test or all tests (ctrl+click) TracyDebugger File Editor integration https://modules.processwire.com/modules/process-nette-tester/
      https://github.com/rolandtoth/ProcessNetteTester
    • By tpr
      Update 2018-07-09: ProcessNetteTester module is available in the Modules Directory and on GitHub.

      This is a short tutorial on how to use Nette Tester with ProcessWire.
      As you will see it's very easy to setup and use and it's perfect for testing your code's functionality. With bootstrapping ProcessWire it's also possible to check the rendered markup of pages using the API, checking page properties, etc. It's also a great tool for module developers for writing better code. 
      While there will be nothing extraordinary here that you couldn't find in Tester's docs this can serve as a good starting point.
      Prerequisites: PHP 5.6+
      01 Download Tester
      Go to https://github.com/nette/tester/releases and download the latest release (currently 2.0.2). Download from the link reading "Source code (zip)". You can use composer also if you wish.
      02 Extract Tester files
      Create a new directory in your site root called "tester". Extract the zip downloaded here, so it should look like this:
      /site /tester/src /tester/tools /tester/appveyor.yml /tester/composer.json /tester/contributing.md /tester/license.md /tester/readme.md /wire ... 03 Create directory for test files
      Add a new directory in "/tester" called "tests". Tester recognizes "*.Test.php" and "*.phpt" files in the tests directory, recursively. 
      04 Create your first test
      In the "tests" directory create a new "MyTest.php" file.
      The first test is a very simple one that bootstraps ProcessWire and checks if the Home page name is "Home". This is not the smartest test but will show you the basics.
      Add this to "/tester/tests/MyTest.php":
      <?php namespace ProcessWire; use \Tester\Assert; use \Tester\DomQuery; use \Tester\TestCase; use \Tester\Environment; require __DIR__ . '/../src/bootstrap.php'; // load Tester require __DIR__ . '/../../index.php'; // bootstrap ProcessWire Environment::setup(); class MyTest extends TestCase {     // first test (step 04)     public function testHomeTitle()     {         $expected = 'Home'; // we expect the page title to be "Home"         $actual = wire('pages')->get(1)->title; // check what's the actual title Assert::equal($expected, $actual); // check whether they are equal     }     // second test will go here (step 06)     // third test will go here (step 07) } // run testing methods (new MyTest())->run(); I've added comment placeholders for the second and third tests that we will insert later.
      05 Run Tester
      Tester can be run either from the command line or from the browser. The command line output is more verbose and colored while in the browser it's plain text only (see later).
      Running from the command line
      Navigate to the "/tester" directory in your console and execute this:
      php src/tester.php -C tests This will start "/tester/src/tester.php" and runs test files from the "/tester/tests" directory. The "-C" switch tells Tester to use the system-wide php ini file, that is required here because when bootstrapping ProcessWire you may run into errors (no php.ini file is used by default). You may load another ini file with the "-c <path>" (check the docs).
      If the title of your Home page is "Home" you should see this:

      If it's for example "Cats and Dogs", you should see this:

      Running from the browser
      First we need to create a new PHP file in ProcessWire's root, let's call it "testrunner.php". This is because ProcessWire doesn't allow to run PHP files from its "site" directory.
      The following code runs two test classes and produces a legible output. IRL you should probably iterate through directories to get test files (eg. with glob()), and of course it's better not allow tests go out to production.
      <?php ini_set('html_errors', false); header('Content-type: text/plain'); echo 'Starting tests.' . PHP_EOL; echo '--------------------------' . PHP_EOL; $file = __DIR__ . '/PATH_TO/FirstTest.php'; echo basename($file) . ' '; require $file; echo '[OK]' . PHP_EOL; $file = __DIR__ . '/PATH_TO/SecondTest.php'; echo basename($file) . ' '; require $file; echo '[OK]' . PHP_EOL; echo '--------------------------' . PHP_EOL; echo 'Tests finished.'; exit; Navigate to "DOMAIN/testrunner.php" in your browser to execute the file. If every test succeeds you should get this:

      If there are failed tests the execution stops and you can read the error message. If there were more tests (eg. ThirdTest), those won't be displayed under the failed test.

      06 DOM test
      This test will check if a page with "basic-page" template has a "h1" element. We will create the page on the fly with ProcessWire's API. To keep things simple we will add the new test as a new method to our MyTest class.
      Add this block to the MyTest class:
      public function testBasicPageHeadline() {     $p = new Page();     $p->template = 'basic-page';     $html = $p->render();     $dom = DomQuery::fromHtml($html);     Assert::true($dom->has('h1')); } This will most likely be true but of course you can check for something more specific, for example "div#main". Note that we have used the DomQuery helper here (check the "use" statement on the top of the file).
      07 Custom function test
      You will probably want to make sure your custom functions/methods will work as they should so let's write a test that demonstrates this.
      I don't want to complicate things so I'll check if the built-in "pageName" sanitizer works as expected. Add this to the myTest class:
      public function testPageNameSanitizer() {     $expected = 'hello-world';     $actual = wire('sanitizer')->pageName('Hello world!', true);     Assert::equal($expected, $actual); } This should also be true. Try to change the expected value if you are eager to see a failure message.
       
      08 Next steps
      You can add more methods to the MyTest class or create new files in the "tests" directory. Check out the range of available Assertions and other features in the docs and see how they could help you writing more fail-safe code.
      Once you make a habit of writing tests you'll see how it can assist making your code more bulletproof and stable. Remember: test early, test often 🙂
      If you find out something useful or cool with Tester make sure to share.
    • By chrizz
      I am currently developing a little application which is supposed to run on a raspberry. I decided to take advantage of PW's capabilities to deal with data not only in the sense of a common website but more as an application. 
      I wrote a lot of modules for this and I wrote tests for some of the methods (using PHPUnit). This works fine so far for methods which are independent from database (e.g. calculating an upcoming date/time by a given schedule). Now I am thinking of extending this by writing tests also for methods which rely on data from the database (e.g. it need to be checked if something exists on that given date/time). This information is currently stored as page in PW. 
      One of my thoughts: create necessary pages in PW for running the test and delete them afterwards. But somehow this just feels wrong. 
      Any kind of input how you would tackle this would be great!
      Thanks a lot! 
       
    • By Mirza
      Hi All,
      I am trying to include the unit tests into our project which is in Processwire(I love it).
      Directory structure for the same will be shown below

       
      Here PWTestCase.php is the parent file where it is getting inherited from the PHPUNIT framework TestCase class and It is in same namespace ProcessWire; as shown below

      Now I have a basic unit test(FuncTest.php as shown below) to check the language calling the function getLanguage() residing in site/templates/_func.php.

      ***when try to run this test, getting undefined function getLanguage as shown below***

       
      Since I am using namespace ProcessWire; & bootstraping processwire in PWTestCase why i am not able to access functions? Can you please suggest where i am going wrong?
      Thanks in advance.
    • By FrancisChung
      PHPStorm for PW Devs
      This thread is a place for ProcessWire developers who use PHPStorm to share their experience, tips, frustrations, solutions, code snippets and generally discuss all things PHPStorm.
      From Wikipedia:
       
      Thanks @kongondo for the Visual Studio Code post earlier.