13

I've looked at the Automated Unit test Execution Recipe and see that it will email the results of running unit tests. I do not see anything in it that gives code coverage results, though. I'd like to be able to run all of the unit tests and get the information that is in the ApexTestResult records (pass/fail, stack, message, etc.) which this recipe does do and also have code coverage results included in the email, which it doesn't.

I see the ApexClass and ApexTrigger objects, but they don't have any sort of code coverage field as far as I can tell.

Is there a way to get code coverage results through Apex such as there is way to get test results using ApexTestResults? Preferably the solution would allow me to selectively specify on which classes I want coverage results, but that's not absolutely necessary.

9

For synchronous testing: The RunTestsResult returned by calling ApexService.runTests() contains a codeCoverage property, which is a CodeCoverageResult array.

The CodeCoverageResult locationsNotCovered property gives you the line number and column of code that wasn't tested in the run.

My experience with this is that it will return code coverage results for any class that gets touched by the test case(s) and you don't get any control over it. Also, it isn't a cumulative view of the code coverage, only what is covered by that specific test run. So you can get lots of results indicating very low code coverage for classes that weren't being targeted by the test cases in the run but might otherwise have really good coverage.


Internally Salesforce appear to be tracking the results of previous tests runs. The Code coverage page https://na2.salesforce.com/setup/build/viewCodeCoverage.apexp?id=01p400000000XYZ has that drop box at the top that can toggle between previous run results. Sadly I've never been able to query this data programmatically. You can access this data via the Tooling API and the ApexCodeCoverage records.

0

Yes, there's a way to get code coverage results through Apex such as there is way to get test results using ApexTestResults.

After running tests, you can view code coverage information in the Tests tab of the Developer Console. The code coverage pane includes coverage information for each Apex class and the overall coverage for all Apex code in your organization.

However, you can also use SOQL queries with Tooling API as an alternative way of checking code coverage and a quick way to get more details. Let's see how it works:

Inspecting Code Coverage

Step 1 - Get your org's base URL:

If you know what's your Org's base URL (you can get it from your browser as well), please skip to step 2.

From Developer Console, open the Debug menu, then select the option "Open Execute Anonymous Window (CTRL+E)

Enter the following Apex Code:

String baseURL = 'https://' + System.URL.getSalesforceBaseUrl().getHost();
System.debug(baseURL);

Click the button [Execute]

Go to your Logs tab on the bottom of the page Copy the full Base URL, including https://

Step 2 - Now you know your base URL, create a Remote Site Setting

  • From Setup, enter Remote Site Settings in the Quick Find box, then select Remote Site Settings.

  • Click New Remote Site.

  • Enter a descriptive term for the Remote Site Name.

  • Enter the URL for the remote site.

  • Optionally, enter a description of the site.

enter image description here

Click Save.

Just in case, create a second Remote Site Settings for the URL in the "Visual.force.com" format:

https://c.xx99.visual.force.com - where xx99 is the code of your org's instance.

Step 3 - Get the JSON file for the Code Coverage Wrapper

Go back to the Developer Console, open the Debug menu, then select the option "Open Execute Anonymous Window (CTRL+E)

Enter the following Apex Code:

String baseURL =  'https://' + System.URL.getSalesforceBaseUrl().getHost();

string queryStr = 'SELECT+NumLinesCovered,ApexClassOrTriggerId,ApexClassOrTrigger.Name,NumLinesUncovered,Coverage+FROM+ApexCodeCoverageAggregate';

String ENDPOINT = baseURL + '/services/data/v40.0/tooling/';

HttpRequest req = new HttpRequest();

req.setEndpoint(ENDPOINT + 'query/?q=' + queryStr);
req.setHeader('Authorization', 'Bearer ' + UserInfo.getSessionID());
req.setHeader('Content-Type', 'application/json');
req.setMethod('GET');
req.setTimeout(80000);

Http http = new Http();
HTTPResponse res = http.send(req);

System.debug(res.getBody());

Click the button [Execute]

Go to your Logs tab on the bottom of the page

Copy the full JSON result

Please note you will need to remove any text before the first "{" bracket. Eg.: "hh:mm:ss:999 USER_DEBUG [99]|DEBUG|"

Go to the website JSON2Apex: https://json2apex.herokuapp.com/

Paste your JSON file

Enter the name for the generated class (here, we will use CodeCoverageWrapper)

Click the button [Create Apex]

enter image description here

Download the generated zip file that contains the Wrapper class plus the Test class.

Extract the files

Open the files with your prefered text editor

Check if everything looks good, and add these classes to your Salesforce org as new classes.

This is how it looks like:

public class CodeCoverageWrapper {

    public class ApexClassOrTrigger {
        public Attributes attributes {get;set;} 
        public String Name {get;set;} 

        public ApexClassOrTrigger(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'attributes') {
                            attributes = new Attributes(parser);
                        } else if (text == 'Name') {
                            Name = parser.getText();
                        } else {
                            System.debug(LoggingLevel.WARN, 'ApexClassOrTrigger consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public Integer size {get;set;} 
    public Integer totalSize {get;set;} 
    public Boolean done {get;set;} 
    public Object queryLocator {get;set;} 
    public String entityTypeName {get;set;} 
    public List<Records> records {get;set;} 

    public CodeCoverageWrapper(JSONParser parser) {
        while (parser.nextToken() != System.JSONToken.END_OBJECT) {
            if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                String text = parser.getText();
                if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                    if (text == 'size') {
                        size = parser.getIntegerValue();
                    } else if (text == 'totalSize') {
                        totalSize = parser.getIntegerValue();
                    } else if (text == 'done') {
                        done = parser.getBooleanValue();
                    } else if (text == 'queryLocator') {
                        queryLocator = parser.readValueAs(Object.class);
                    } else if (text == 'entityTypeName') {
                        entityTypeName = parser.getText();
                    } else if (text == 'records') {
                        records = arrayOfRecords(parser);
                    } else {
                        System.debug(LoggingLevel.WARN, 'CodeCoverageWrapper consuming unrecognized property: '+text);
                        consumeObject(parser);
                    }
                }
            }
        }
    }

    public class Coverage_X {
        public List<Integer> coveredLines {get;set;} 
        public List<CoveredLines> uncoveredLines {get;set;} 

        public Coverage_X(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'coveredLines') {
                            coveredLines = arrayOfInteger(parser);
                        } else if (text == 'uncoveredLines') {
                            uncoveredLines = arrayOfCoveredLines(parser);
                        } else {
                            System.debug(LoggingLevel.WARN, 'Coverage_X consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class Coverage_Y {
        public List<CoveredLines> coveredLines {get;set;} 
        public List<Integer> uncoveredLines {get;set;} 

        public Coverage_Y(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'coveredLines') {
                            coveredLines = arrayOfCoveredLines(parser);
                        } else if (text == 'uncoveredLines') {
                            uncoveredLines = arrayOfInteger(parser);
                        } else {
                            System.debug(LoggingLevel.WARN, 'Coverage_Y consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class Coverage_Z {
        public List<CoveredLines> coveredLines {get;set;} 
        public List<CoveredLines> uncoveredLines {get;set;} 

        public Coverage_Z(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'coveredLines') {
                            coveredLines = arrayOfCoveredLines(parser);
                        } else if (text == 'uncoveredLines') {
                            uncoveredLines = arrayOfCoveredLines(parser);
                        } else {
                            System.debug(LoggingLevel.WARN, 'Coverage_Z consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class Attributes {
        public String type_Z {get;set;} // in json: type
        public String url {get;set;} 

        public Attributes(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'type') {
                            type_Z = parser.getText();
                        } else if (text == 'url') {
                            url = parser.getText();
                        } else {
                            System.debug(LoggingLevel.WARN, 'Attributes consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class Coverage {
        public List<Integer> coveredLines {get;set;} 
        public List<Integer> uncoveredLines {get;set;} 

        public Coverage(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'coveredLines') {
                            coveredLines = arrayOfInteger(parser);
                        } else if (text == 'uncoveredLines') {
                            uncoveredLines = arrayOfInteger(parser);
                        } else {
                            System.debug(LoggingLevel.WARN, 'Coverage consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class CoveredLines {

        public CoveredLines(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        {
                            System.debug(LoggingLevel.WARN, 'CoveredLines consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public class Records {
        public Attributes attributes {get;set;} 
        public Integer NumLinesCovered {get;set;} 
        public String ApexClassOrTriggerId {get;set;} 
        public ApexClassOrTrigger ApexClassOrTrigger {get;set;} 
        public Integer NumLinesUncovered {get;set;} 
        public Coverage Coverage {get;set;} 

        public Records(JSONParser parser) {
            while (parser.nextToken() != System.JSONToken.END_OBJECT) {
                if (parser.getCurrentToken() == System.JSONToken.FIELD_NAME) {
                    String text = parser.getText();
                    if (parser.nextToken() != System.JSONToken.VALUE_NULL) {
                        if (text == 'attributes') {
                            attributes = new Attributes(parser);
                        } else if (text == 'NumLinesCovered') {
                            NumLinesCovered = parser.getIntegerValue();
                        } else if (text == 'ApexClassOrTriggerId') {
                            ApexClassOrTriggerId = parser.getText();
                        } else if (text == 'ApexClassOrTrigger') {
                            ApexClassOrTrigger = new ApexClassOrTrigger(parser);
                        } else if (text == 'NumLinesUncovered') {
                            NumLinesUncovered = parser.getIntegerValue();
                        } else if (text == 'Coverage') {
                            Coverage = new Coverage(parser);
                        } else {
                            System.debug(LoggingLevel.WARN, 'Records consuming unrecognized property: '+text);
                            consumeObject(parser);
                        }
                    }
                }
            }
        }
    }

    public static CodeCoverageWrapper parse(String json) {
        System.JSONParser parser = System.JSON.createParser(json);
        return new CodeCoverageWrapper(parser);
    }

    public static void consumeObject(System.JSONParser parser) {
        Integer depth = 0;
        do {
            System.JSONToken curr = parser.getCurrentToken();
            if (curr == System.JSONToken.START_OBJECT || 
                curr == System.JSONToken.START_ARRAY) {
                depth++;
            } else if (curr == System.JSONToken.END_OBJECT ||
                curr == System.JSONToken.END_ARRAY) {
                depth--;
            }
        } while (depth > 0 && parser.nextToken() != null);
    }

    private static List<Integer> arrayOfInteger(System.JSONParser p) {
        List<Integer> res = new List<Integer>();
        if (p.getCurrentToken() == null) p.nextToken();
        while (p.nextToken() != System.JSONToken.END_ARRAY) {
            res.add(p.getIntegerValue());
        }
        return res;
    }

    private static List<Records> arrayOfRecords(System.JSONParser p) {
        List<Records> res = new List<Records>();
        if (p.getCurrentToken() == null) p.nextToken();
        while (p.nextToken() != System.JSONToken.END_ARRAY) {
            res.add(new Records(p));
        }
        return res;
    }

    private static List<CoveredLines> arrayOfCoveredLines(System.JSONParser p) {
        List<CoveredLines> res = new List<CoveredLines>();
        if (p.getCurrentToken() == null) p.nextToken();
        while (p.nextToken() != System.JSONToken.END_ARRAY) {
            res.add(new CoveredLines(p));
        }
        return res;
    }

}

Step 4 - Create a Helper Class

Create the following class, that contains a method that will query and return information about the Code Coverage

public class CodeCoverageHelper {

    //This method will return a Map of Classes Names and the respective Code Coverage
    public static Map<String, Decimal> getCodeCoverage() {
        Map<String, Decimal> resultMap = new Map<String, Decimal>();

        string queryStr = 'SELECT+NumLinesCovered,ApexClassOrTriggerId,ApexClassOrTrigger.Name,NumLinesUncovered,Coverage+FROM+ApexCodeCoverageAggregate+ORDER+BY+ApexClassOrTrigger.Name';

        String ENDPOINT = 'https://' + System.URL.getSalesforceBaseUrl().getHost() + '/services/data/v40.0/tooling/';

        HttpRequest req = new HttpRequest();

        req.setEndpoint(ENDPOINT + 'query/?q=' + queryStr);
        req.setHeader('Authorization', 'Bearer ' + UserInfo.getSessionID());
        req.setHeader('Content-Type', 'application/json');
        req.setMethod('GET');
        req.setTimeout(80000);

        Http http = new Http();
        HTTPResponse res = http.send(req);

        if (res.getStatusCode() == 200) {
            CodeCoverageWrapper codeCoverageWrapper = CodeCoverageWrapper.parse(res.getBody());

            for(CodeCoverageWrapper.Records records : codeCoverageWrapper.records) {

                String classOrTriggerName  = records.ApexClassOrTrigger.Name;
                Decimal numLinesCovered    = records.NumLinesCovered;
                Decimal numLinesUncovered  = records.NumLinesUncovered;
                Decimal totalNumberOfLines = numLinesCovered + numLinesUncovered;

                if(totalNumberOfLines == 0) continue;

                Decimal coveragePercentage = (numLinesCovered / totalNumberOfLines) * 100;

                resultMap.put(classOrTriggerName, coveragePercentage);
            }
        }

        return resultMap;
    }

    // Method created to sort the Map of Coverage values in Descending Order
    public static Map<String, Decimal> sortCodeCoverageMapByCoverage(Map<String, Decimal> coverageMap) {
        CoverageWrapper[] coverageList = new CoverageWrapper[]{};

        for(String key : coverageMap.keySet()) {
            coverageList.add(new CoverageWrapper(key, coverageMap.get(key)));
        }

        coverageList.sort();

        CoverageWrapper[] finalList = new CoverageWrapper[]{};

        for(Integer i = coverageList.size() -1; i >= 0; i = i-1 ) {
            finalList.add(coverageList.get(i));
        }

        Map<String,Decimal> coverageToNameMap = new Map<String,Decimal>();

        for(CoverageWrapper coverage : finalList) {
            coverageToNameMap.put(coverage.getObjectName(), coverage.getValue());
        }

        return coverageToNameMap;
    }

    public static String buildCodeCoverageMessage(Decimal coverage, String objectName) {
        String coverageMessage = ''; 

        if(coverage < 10) {
            coverageMessage = coverageMessage + ICON_ERROR + ' ' + MESSAGE_UNDER_10 +  ' threshold';
        }

        if(coverage >= 10 && coverage < 75) {
            coverageMessage = coverageMessage + ICON_WARNING + ' ' + MESSAGE_UNDER_75 + ' threshold';
        }

        if(coverage >= 75) {
            coverageMessage = coverageMessage + ICON_OK + ' ' + MESSAGE_ABOVE_75 + ' threshold';
        }

        coverageMessage = coverageMessage + ' | Code Coverage for [ ' + objectName + ' ]: ' + coverage + '%'; 

        return coverageMessage;
    }

    public static final String ICON_ERROR   = '⛔';
    public static final String ICON_WARNING = '️⚠️';
    public static final String ICON_OK      = '✅';

    public static final String MESSAGE_UNDER_10 = 'Under the 10%';
    public static final String MESSAGE_UNDER_75 = 'Under the 75%';
    public static final String MESSAGE_ABOVE_75 = 'Above the 75%';

    public class CoverageWrapper implements Comparable {
        private Decimal coverageValue{get; set;}
        private String objectName    {get; set;}
        private Integer intValue     {get; set;}

        CoverageWrapper(String objectName, Decimal coverageValue) {
            this.objectName = objectName;
            this.coverageValue = coverageValue;
            this.intValue = coverageValue.intValue();
        }

        public Decimal getValue() {
            return this.coverageValue;
        }

        public String getObjectName() {
            return this.objectName;
        }

        public Integer compareTo(Object other) {
            return intValue-((CoverageWrapper)other).intValue;
        }
    }
}

Don't forget to write the test class for it.

Step 5 - Create a Controller and a Page to display the Code Coverage

Create the following class, that contains the logic for the page

public class CodeCoverageController {

    public String messageUnder10 {
        get { return CodeCoverageHelper.MESSAGE_UNDER_10; }
        private set;
    }

    public String messageUnder75 {
        get { return CodeCoverageHelper.MESSAGE_UNDER_75; }
        private set;
    } 

    public String messageAbove75 {
        get { return CodeCoverageHelper.MESSAGE_ABOVE_75; }
        private set;
    }  

    public String[] codeCoverageMessages {
        get {
            if(codeCoverageMessages == null) codeCoverageMessages = new String[]{};
            return codeCoverageMessages;
        }
        set;
    }

    public Map<String, Decimal> codeCoverageMap {
        get {
            if(codeCoverageMap == null || codeCoverageMap.isEmpty()) {
                codeCoverageMap = CodeCoverageHelper.getCodeCoverage();
            }
            return codeCoverageMap;                
        }

        set;
    }

    public CodeCoverageController() {
        populateCodeCoverageByName();
    }

    public void populateCodeCoverageByName() {
        Map<String, Decimal> coverageMap = codeCoverageMap;
        populateCodeCoverageInfo(coverageMap);

    }

    public void populateCodeCoverageByCoverage() {
        Map<String, Decimal> coverageMap = CodeCoverageHelper.sortCodeCoverageMapByCoverage(codeCoverageMap);
        populateCodeCoverageInfo(coverageMap);
    }

    public void populateCodeCoverageInfo(Map<String, Decimal> coverageMap){
        codeCoverageMessages.clear();

        for(String className : coverageMap.keySet()) {
            Decimal coverage = coverageMap.get(className);
            coverage = coverage.setScale(2);

            String coverageMessage = CodeCoverageHelper.buildCodeCoverageMessage(coverage, className);
            codeCoverageMessages.add(coverageMessage);
        }
    }

}

Create the following page, using the controller we just created.

Please note we're leveraging Salesforce Lightning Design System;

<apex:page controller="CodeCoverageController" showHeader="true" sidebar="false" docType="html-5.0">
    <apex:slds />
    <apex:form id="codeCoverageForm">
        <apex:outputPanel id="codeCoverageRepeat" style="justify-content: left;">
            <br/>
            <div class="centered">
                <div class="slds-text-heading_large">
                    <span>
                        <h2 style="justify-content: center;">Code Coverage information: </h2>
                    </span>
                </div>
            </div>
            <br/>

            <apex:outputPanel id="buttonsPanel">
                <div class="text-center">
                    <apex:outputPanel>
                        <apex:commandButton action="{!populateCodeCoverageByName}" value="Sort by Name" reRender="codeCoverageForm" styleClass="slds-button slds-button_outline-brand slds-button--neutral slds-not-selected"  />
                    </apex:outputPanel>
                    <span> or </span>
                    <apex:outputPanel>
                        <apex:commandButton action="{!populateCodeCoverageByCoverage}" value="Sort by Coverage" reRender="codeCoverageForm" styleClass="slds-button slds-button_outline-brand slds-button--neutral slds-not-selected" />
                    </apex:outputPanel>
                </div>
            </apex:outputPanel>

            <br/>

            <apex:repeat value="{!codeCoverageMessages}" var="codeCoverageMessage">

                <!-- Code coverage is below 10% -->
                <apex:outputPanel  rendered="{!if(contains(codeCoverageMessage,messageUnder10),'true','false')}" style="justify-content: left;">
                    <div class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_error" role="alert" style="justify-content: left;">
                        <span class="slds-assistive-text">error</span>
                        <h2 style="justify-content: left;">{!codeCoverageMessage}</h2>
                    </div>
                </apex:outputPanel>

                <!-- Code coverage is under 75% -->
                <apex:outputPanel rendered="{!if(contains(codeCoverageMessage,messageUnder75),'true','false')}" style="justify-content: left;">
                    <div class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_warning" role="alert" style="justify-content: left;">
                        <span class="slds-assistive-text">warning</span>
                        <h2 style="justify-content: left;">{!codeCoverageMessage}</h2>
                    </div>
                </apex:outputPanel>

                <!-- Code coverage is above 75% -->
                <apex:outputPanel rendered="{!if(contains(codeCoverageMessage,messageAbove75),'true','false')}" style="justify-content: left;">
                    <div class="slds-notify slds-notify_alert slds-theme_alert-texture slds-theme_info" role="alert" style="justify-content: left;">
                        <span class="slds-assistive-text">info</span>
                        <h2 style="justify-content: left;">{!codeCoverageMessage}</h2>
                    </div>
                </apex:outputPanel>

            </apex:repeat>
        </apex:outputPanel>
    </apex:form>
</apex:page>

The page has two buttons. One to sort the results by Class / Trigger name, and another button to sort the results by Code Coverage.

This is how it looks like:

enter image description here enter image description here

Final Considerations

If you want to specify on which classes you want coverage results, you can create a list of classes names to dynamically filter down using the Query String, or get all the results and filter the Map of classes by name.

You can follow the same logic to use the Tooling API whenever you need to query exposed metadata used in developer tooling that you can access through REST or SOAP.

Also, the method sortCodeCoverageMapByCoverage from CodeCoverageHelper is quite handy and can be used as an example for situations when you need to sort Maps by its values. In this case, sort the Code Coverage percentage value.

Just don't forget to adapt the Wrapper and the Comparable.

You can find all code used for this project in my blog or my GitHub account, at github.com/toadgeek/QueryCodeCoverage

I hope it helps!

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.